Rework Gradient into a newtype of List<Color> with optional position and midpoint attributes (#4397)

* Rework Gradient into a newtype of List<Color> with optional position and midpoint attributes

* Fix Vello stopless-gradient fallback coverage, empty legacy gradient tables, the node docs gradient swatch, NaN position elision, and wired setter input overwrites
This commit is contained in:
Keavon Chambers
2026-09-10 20:23:55 +00:00
committed by Dennis Kobert
parent 4c8bddea9f
commit f688e4d478
35 changed files with 1105 additions and 446 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod brush_stroke;
pub mod migrations {
use crate::brush_stroke::BrushStroke;
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
pub fn migrate_to_brush_strokes<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<BrushStroke>, D::Error> {
use serde::Deserialize;
+2 -11
View File
@@ -15,7 +15,7 @@ use raster_types::{CPU, GPU, Raster};
use std::cmp::Ordering;
use vector_types::gradient::{GradientSpreadMethod, GradientType as GradientTypeValue};
use vector_types::{Gradient, GradientStop, ReferencePoint};
use vector_types::{Gradient, ReferencePoint};
/// Resolves a signed index over `total` lanes: negatives count from the end,
/// out of range resolves to nothing.
@@ -999,16 +999,7 @@ pub fn flatten_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<
/// Constructs a gradient from a `Color[]`, where the colors are evenly distributed as gradient stops across the range from 0 to 1.
#[node_macro::node(category("Color"), name("Colors to Gradient"))]
fn colors_to_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] colors: T) -> Gradient {
let colors = colors.into_flattened_list::<Color>();
let stop = |position: f64, color: Color| GradientStop { position, midpoint: 0.5, color };
match colors.len() {
0 => Gradient::new(vec![stop(0., Color::BLACK), stop(1., Color::BLACK)]),
1 => Gradient::new(vec![
stop(0., colors.element(0).copied().unwrap_or(Color::BLACK)),
stop(1., colors.element(0).copied().unwrap_or(Color::BLACK)),
]),
total => Gradient::new(colors.into_iter().enumerate().map(|(index, row)| stop(index as f64 / (total - 1) as f64, row.into_element()))),
}
Gradient::from(colors.into_flattened_list::<Color>())
}
#[cfg(test)]
+32 -5
View File
@@ -1199,7 +1199,7 @@ fn hex_to_color(ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, hex_code: Str
/// Constructs a gradient value which may be set to any sequence of color stops to represent the transition between colors.
#[node_macro::node(category("Value"))]
fn gradient_value(_: impl Ctx, _primary: (), gradient: Gradient) -> Gradient {
fn gradient_value(_: impl Ctx, _primary: (), #[default(Color::BLACK, Color::WHITE)] gradient: Gradient) -> Gradient {
gradient
}
@@ -1215,16 +1215,43 @@ fn spread_method(_: impl Ctx, gradient: Gradient, spread_method: vector_types::G
(gradient, Attr(spread_method))
}
/// Gets the color at the specified position along the gradient, given a position from 0 (left) to 1 (right).
/// Sets the position of each of a gradient's stops, a factor from 0 to 1 along the gradient.
///
/// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each stop to its default evenly spaced position.
#[node_macro::node(category("Color"))]
fn sample_gradient(ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, _primary: (), gradient: IList<Gradient>, position: Fraction) -> Result<IList<Color>, Interrupt> {
fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: List<f64>) -> Gradient {
let positions: Vec<f64> = positions.iter_element_values().copied().collect();
gradient.set_positions(&positions);
gradient
}
/// Sets the interpolation midpoint for each interval between gradient stops, a factor from 0 to 1 where the 0.5 default means linear interpolation and another value skews the transition speed toward one stop or the other.
///
/// The final stop belongs to no interval so its midpoint is ignored.
///
/// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each midpoint to its default of 0.5.
#[node_macro::node(category("Color"))]
fn gradient_midpoints(_: impl Ctx, mut gradient: Gradient, midpoints: List<f64>) -> Gradient {
let midpoints: Vec<f64> = midpoints.iter_element_values().copied().collect();
gradient.set_midpoints(&midpoints);
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 `spread_method` attribute: Pad (default), Reflect, or Repeat.
#[node_macro::node(category("Color"))]
fn sample_gradient(
ctx: impl Ctx + ExtractIndex + InjectIndex + Copy,
_primary: (),
#[default(Color::BLACK, Color::WHITE)] gradient: IList<Gradient>,
position: Fraction,
) -> Result<IList<Color>, Interrupt> {
// An unwired gradient serves an empty level: no color
if gradient.is_empty() || ctx.index() != 0 {
return Err(GraphError::past_end().into());
}
let position = position.clamp(0., 1.);
Ok(gradient.element_ref(0).evaluate(position))
let spread_method = gradient.lane(0).attr::<SpreadMethodAttr>();
Ok(gradient.element_ref(0).evaluate(position, spread_method))
}
/// Constructs a footprint value which may be set to any transformation of a unit square describing a render area, and a render resolution at least 1x1 integer pixels.
+1 -3
View File
@@ -24,9 +24,7 @@ mod adjust_std {
}
impl Adjust<Color> for Gradient {
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
for color in self.color.iter_mut() {
*color = map_fn(color);
}
*self = self.map_colors(map_fn);
}
}
}
@@ -38,16 +38,19 @@ mod blend_std {
impl Blend<Color> for Gradient {
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self {
let mut combined_stops = self.position.iter().chain(under.position.iter()).copied().collect::<Vec<_>>();
combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
let mut combined_stops = self.positions().into_iter().chain(under.positions()).collect::<Vec<_>>();
combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
let stops = combined_stops.into_iter().map(|position| {
let over_color = self.evaluate(position);
let under_color = under.evaluate(position);
let over_color = self.evaluate(position, Default::default());
let under_color = under.evaluate(position, Default::default());
let color = blend_fn(over_color, under_color);
GradientStop { position, midpoint: 0.5, color }
});
Gradient::new(stops)
let mut gradient = Gradient::new(stops);
gradient.elide_default_attributes();
gradient
}
}
}
+3 -2
View File
@@ -17,18 +17,19 @@ fn gradient_map<T: Adjust<Color> + Clone + Send + Sync + core_types::CacheHash +
Gradient,
)]
mut image: T,
gradient: IList<Gradient>,
#[default(Color::BLACK, Color::WHITE)] gradient: IList<Gradient>,
reverse: bool,
) -> T {
if gradient.is_empty() {
return image;
}
let spread_method = gradient.lane(0).attr::<vector_types::markers::SpreadMethod>();
let gradient = gradient.element_ref(0);
image.adjust(|color| {
let intensity = color.luminance_rec_709();
let intensity = if reverse { 1. - intensity } else { intensity };
gradient.evaluate(intensity as f64)
gradient.evaluate(intensity as f64, spread_method)
});
image
+1 -1
View File
@@ -64,7 +64,7 @@ impl Default for Font {
}
}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
use serde::Deserialize;
String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name })
+6 -7
View File
@@ -72,7 +72,7 @@ fn assign_color_at(gradient: &Gradient, position: usize, length: usize, randomiz
_ => position as f64 % repeat_every as f64 / (repeat_every - 1) as f64,
},
};
gradient.evaluate(factor)
gradient.evaluate(factor, Default::default())
}
/// Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient.
@@ -88,6 +88,7 @@ fn assign_colors<'e>(
/// Whether to style the stroke.
stroke: bool,
/// The range of colors to select from.
#[default(Color::BLACK, Color::WHITE)]
#[widget(ParsedWidgetOverride::Custom = "assign_colors_gradient")]
gradient: IList<Gradient>,
/// Whether to reverse the gradient.
@@ -337,7 +338,7 @@ fn fill<'e>(
#[default(Color::BLACK)]
fill: IList<Graphic<'static>>,
_backup_color: IList<Color>,
_backup_gradient: IList<Gradient>,
#[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList<Gradient>,
_gradient_type: GradientType,
_spread_method: GradientSpreadMethod,
_has_transform: bool,
@@ -358,7 +359,7 @@ fn fill_graphic_leveled<'e>(
(element, _content_fill): (Graphic<'static>, Attr<Fill>),
#[default(Color::BLACK)] fill: IList<Graphic<'static>>,
_backup_color: IList<Color>,
_backup_gradient: IList<Gradient>,
#[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList<Gradient>,
_gradient_type: GradientType,
_spread_method: GradientSpreadMethod,
_has_transform: bool,
@@ -2924,14 +2925,12 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
match (a.element(0), b.element(0)) {
(Some(Graphic::Color(color_a)), Some(Graphic::Color(color_b))) => Some(List::new_from_element(Graphic::from(color_a.lerp(color_b, time as f32)))),
(Some(Graphic::Color(color_a)), Some(Graphic::Gradient(stops_b))) => {
let mut solid_to_gradient = stops_b.clone();
solid_to_gradient.color.iter_mut().for_each(|color| *color = *color_a);
let solid_to_gradient = stops_b.map_colors(|_| *color_a);
let stops = solid_to_gradient.lerp(stops_b, time);
Some(gradient_paint(b, stops, None))
}
(Some(Graphic::Gradient(stops_a)), Some(Graphic::Color(color_b))) => {
let mut gradient_to_solid = stops_a.clone();
gradient_to_solid.color.iter_mut().for_each(|color| *color = *color_b);
let gradient_to_solid = stops_a.map_colors(|_| *color_b);
let stops = stops_a.lerp(&gradient_to_solid, time);
Some(gradient_paint(a, stops, None))
}