Add a gradient interpolation space attribute with sRGB Gamma (existing) and sRGB Linear (new) (#4412)

* Build dropdown menu entries from choice type metadata

* Add a gradient interpolation color space attribute, blending stops in linear light by default

* Add a Space interpolation dropdown to the color picker popover

* Stamp legacy gradient ramps with their implicit gamma interpolation during deserialization

* Resolve color-interpolation from SVG style blocks and fix cascade order among repeated declarations
This commit is contained in:
Keavon Chambers
2026-08-05 15:58:25 -07:00
committed by Dennis Kobert
parent ea6f45ddc0
commit f8975b7d94
25 changed files with 817 additions and 120 deletions

View File

@@ -614,6 +614,7 @@ tagged_value! {
GradientForm(vector::style::GradientForm),
#[serde(alias = "GradientSpreadMethod")] // TODO: Eventually remove this document upgrade code
GradientSpread(vector::style::GradientSpread),
GradientInterpolation(vector::style::GradientInterpolation),
ReferencePoint(vector::ReferencePoint),
CentroidType(vector::misc::CentroidType),
BooleanOperation(vector::misc::BooleanOperation),
@@ -862,11 +863,15 @@ pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserialize
.and_then(|c| c.get("element").or_else(|| c.get("instance")).or_else(|| c.get("instances")))
.and_then(|element| element.as_array());
// An empty legacy table wrapper carries no gradient, degrading to the default rather than failing the document load
// An empty legacy table wrapper carries no gradient, degrading to the default (in the era's gamma) rather than failing the document load
if let Some(array) = table_element
&& array.is_empty()
{
return Ok(MemoHash::new(TaggedValue::GradientRamp(GradientRamp::default())));
let ramp = GradientRamp {
gradient_interpolation: vector::style::GradientInterpolation::SrgbGamma,
..Default::default()
};
return Ok(MemoHash::new(TaggedValue::GradientRamp(ramp)));
}
let payload = table_element.and_then(|array| array.first()).unwrap_or(content);
@@ -1088,7 +1093,7 @@ mod paint_default_parsing {
#[cfg(test)]
mod gradient_shape_migration {
use graphic_types::vector_types::GradientSpread;
use graphic_types::vector_types::{GradientInterpolation, GradientSpread};
use super::*;
@@ -1115,9 +1120,29 @@ mod gradient_shape_migration {
let json = serde_json::to_value(&value).unwrap();
assert!(json.get("GradientRamp").and_then(|payload| payload.get("stops")).is_some(), "the payload should nest its stops: {json}");
assert_eq!(
json.get("GradientRamp").and_then(|payload| payload.get("gradient_interpolation")),
Some(&serde_json::json!("SrgbLinear")),
"the interpolation should serialize even at its default, marking the ramp as post-legacy: {json}"
);
assert_eq!(load(json), value);
}
// TODO: Eventually remove this document upgrade code
#[test]
fn ramp_without_interpolation_field_reads_as_legacy_gamma() {
let json = serde_json::json!({ "GradientRamp": { "stops": { "color": [white(), white()] } } });
let TaggedValue::GradientRamp(ramp) = load(json) else {
panic!("the ramp payload should become a gradient ramp value")
};
assert_eq!(
ramp.gradient_interpolation,
GradientInterpolation::SrgbGamma,
"a ramp saved before the field existed should read as gamma"
);
}
// TODO: Eventually remove this document upgrade code
#[test]
fn legacy_flat_stops_parse_faithfully() {
@@ -1125,6 +1150,7 @@ mod gradient_shape_migration {
let TaggedValue::GradientRamp(ramp) = load(json) else {
panic!("the flat stops should become a gradient ramp value")
};
assert_eq!(ramp.gradient_interpolation, GradientInterpolation::SrgbGamma, "the pre-ramp flat form should carry the era's gamma");
let gradient = Gradient::from(ramp);
assert_eq!(gradient.positions(), vec![0., 0.25]);
@@ -1138,6 +1164,7 @@ mod gradient_shape_migration {
let TaggedValue::GradientRamp(ramp) = load(json) else {
panic!("the tuple stops should become a gradient ramp value")
};
assert_eq!(ramp.gradient_interpolation, GradientInterpolation::SrgbGamma, "the pre-ramp tuple form should carry the era's gamma");
let gradient = Gradient::from(ramp);
assert_eq!(gradient.positions(), vec![0., 1.]);
@@ -1148,7 +1175,11 @@ mod gradient_shape_migration {
#[test]
fn empty_legacy_gradient_table_degrades_to_the_default() {
let json = serde_json::json!({ "GradientTable": { "element": [] } });
assert_eq!(load(json), TaggedValue::GradientRamp(GradientRamp::default()));
let expected = GradientRamp {
gradient_interpolation: GradientInterpolation::SrgbGamma,
..Default::default()
};
assert_eq!(load(json), TaggedValue::GradientRamp(expected));
}
// TODO: Eventually remove this document upgrade code

View File

@@ -17,7 +17,7 @@ pub mod migrations {
use crate::Vector;
use core_types::Color;
use vector_types::gradient::GradientStops;
use vector_types::{Gradient, GradientRamp};
use vector_types::{Gradient, GradientInterpolation, GradientRamp};
// Storing legacy structs that are only used in document migration.
// TODO: Eventually remove this document upgrade code
@@ -152,6 +152,7 @@ pub mod migrations {
// TODO: Eventually remove this document upgrade code
/// Recovers a [`GradientRamp`] from any of its on-disk shapes: the current nested form, the flat stops struct
/// that preceded it, or the ancient position-color tuple list (whose even positions elide back to absence).
/// The pre-ramp shapes come from documents that rendered in gamma, so they carry that interpolation explicitly.
pub fn migrate_to_gradient_ramp<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<GradientRamp, D::Error> {
use serde::Deserialize;
@@ -165,13 +166,20 @@ pub mod migrations {
Ok(match GradientRampFormat::deserialize(deserializer)? {
GradientRampFormat::Ramp(ramp) => ramp,
GradientRampFormat::FlatStops(stops) => GradientRamp::from(stops),
GradientRampFormat::FlatStops(stops) => GradientRamp {
gradient_interpolation: GradientInterpolation::SrgbGamma,
..GradientRamp::from(stops)
},
GradientRampFormat::Tuples(stops) => {
let position: Vec<f64> = stops.iter().map(|(position, _)| *position).collect();
let mut gradient = Gradient::from(stops.into_iter().map(|(_, color)| color).collect::<Vec<_>>());
gradient.set_positions(&position);
gradient.elide_default_attributes();
GradientRamp::from(gradient)
GradientRamp {
gradient_interpolation: GradientInterpolation::SrgbGamma,
..GradientRamp::from(gradient)
}
}
})
}

View File

@@ -919,6 +919,14 @@ impl Color {
)
}
/// Like [`Self::lerp`] but interpolating in gamma sRGB space, the space SVG interpolates in between adjacent gradient stops.
#[inline(always)]
pub fn lerp_gamma_srgb(&self, other: &Color, t: f32) -> Self {
let a = self.to_gamma_srgb_channels();
let b = other.to_gamma_srgb_channels();
Color::from_gamma_srgb_channels(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t)
}
/// Generic power curve `c.powf(1 / exponent)` applied per RGB channel. Distinct from the sRGB transfer curve (see [`Self::to_gamma_srgb_channels`]).
/// The expected output must still be treated as linear-light.
#[inline(always)]

View File

@@ -8,11 +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::{GradientForm as GradientFormAttr, GradientSpread as GradientSpreadAttr};
use graphic_types::vector_types::markers::{GradientForm as GradientFormAttr, GradientInterpolation as GradientInterpolationAttr, GradientSpread as GradientSpreadAttr};
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::{GradientInterpolation, GradientSpread};
#[derive(Copy, Clone, PartialEq)]
pub enum PaintTarget {
@@ -111,8 +111,9 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>
let gradient_form: GradientForm = source.attr::<GradientFormAttr>(0);
let local_gradient_transform: DAffine2 = source.attr::<Transform>(0);
let gradient_spread: GradientSpread = source.attr::<GradientSpreadAttr>(0);
let gradient_interpolation: GradientInterpolation = source.attr::<GradientInterpolationAttr>(0);
let (samples, _) = spread_adjusted_samples(stops, gradient_spread, gradient_form, ClearGuardPlacement::SvgStopOrder);
let (samples, _) = spread_adjusted_samples(stops, gradient_spread, gradient_form, gradient_interpolation, ClearGuardPlacement::SvgStopOrder);
for (position, color, original_midpoint) in samples {
stop.push_str("<stop");

View File

@@ -26,8 +26,8 @@ use graphene_resource::Resource;
use graphic_types::graphic::{PaintColumns, PaintOverlay, PaintReach, has_paint, is_paint_present, paint_graphics, set_paint_attribute, vector_can_reduce_to_clip_path};
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};
use graphic_types::vector_types::markers::{GradientForm as GradientFormAttr, GradientSpread as GradientSpreadAttr};
use graphic_types::vector_types::gradient::{Gradient, GradientForm, GradientInterpolation};
use graphic_types::vector_types::markers::{GradientForm as GradientFormAttr, GradientInterpolation as GradientInterpolationAttr, GradientSpread as GradientSpreadAttr};
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};
@@ -422,8 +422,14 @@ pub(crate) enum ClearGuardPlacement {
/// The `Clear` spread brackets the samples with transparent guard stops placed per `guards`: the pad extension then
/// paints transparency outward while hard stops cut the paint off exactly at the unit range's boundaries. A radial
/// gradient's span still starts at zero, since its sampling distance never goes below the center.
pub(crate) fn spread_adjusted_samples(gradient: &Gradient, gradient_spread: GradientSpread, gradient_form: GradientForm, guards: ClearGuardPlacement) -> (GradientSamples, (f64, f64)) {
let samples = gradient.interpolated_samples();
pub(crate) fn spread_adjusted_samples(
gradient: &Gradient,
gradient_spread: GradientSpread,
gradient_form: GradientForm,
gradient_interpolation: GradientInterpolation,
guards: ClearGuardPlacement,
) -> (GradientSamples, (f64, f64)) {
let samples = gradient.interpolated_samples(gradient_interpolation);
if gradient_spread != GradientSpread::Clear {
return (samples, (0., 1.));
}
@@ -508,8 +514,10 @@ fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list
let gradient_form: GradientForm = gradient_list.attr::<GradientFormAttr>(0);
let gradient_transform: DAffine2 = gradient_list.attr::<Transform>(0);
let gradient_spread: GradientSpread = gradient_list.attr::<GradientSpreadAttr>(0);
let gradient_interpolation: GradientInterpolation = gradient_list.attr::<GradientInterpolationAttr>(0);
let (samples, span) = spread_adjusted_samples(stops, gradient_spread, gradient_form, gradient_interpolation, ClearGuardPlacement::VelloRampTexels);
let (samples, span) = spread_adjusted_samples(stops, gradient_spread, gradient_form, ClearGuardPlacement::VelloRampTexels);
let peniko_stops = peniko_color_stops(&samples);
// The unit gradient is placed by the desheared frame so a non-uniform transform produces the intended ellipse
@@ -2403,6 +2411,7 @@ fn render_gradient_svg<S: LaneSource<Element = Gradient>>(source: &S, render: &m
let opacity_fill_attr: f64 = source.attr::<OpacityFill>(index);
let gradient_spread: GradientSpread = source.attr::<GradientSpreadAttr>(index);
let gradient_form: GradientForm = source.attr::<GradientFormAttr>(index);
let gradient_interpolation: GradientInterpolation = source.attr::<GradientInterpolationAttr>(index);
let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" };
render.leaf_tag(tag, |attributes| {
if let Some((min, size)) = thumbnail_rect {
@@ -2418,7 +2427,7 @@ fn render_gradient_svg<S: LaneSource<Element = Gradient>>(source: &S, render: &m
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
}
let (samples, _) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, ClearGuardPlacement::SvgStopOrder);
let (samples, _) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, gradient_interpolation, ClearGuardPlacement::SvgStopOrder);
let mut stop_string = String::new();
for (position, color, original_midpoint) in samples {
@@ -2489,6 +2498,7 @@ fn render_gradient_vello<S: LaneSource<Element = Gradient>>(source: &S, scene: &
let Some(gradient) = source.element(index) else { continue };
let gradient_spread: GradientSpread = source.attr::<GradientSpreadAttr>(index);
let gradient_form: GradientForm = source.attr::<GradientFormAttr>(index);
let gradient_interpolation: GradientInterpolation = source.attr::<GradientInterpolationAttr>(index);
let transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
let opacity_attr: f64 = source.attr::<Opacity>(index);
@@ -2498,7 +2508,7 @@ fn render_gradient_vello<S: LaneSource<Element = Gradient>>(source: &S, scene: &
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 (samples, span) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, ClearGuardPlacement::VelloRampTexels);
let (samples, span) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, gradient_interpolation, ClearGuardPlacement::VelloRampTexels);
let stops = peniko_color_stops(&samples);
let extend = peniko_extend(gradient_spread);
@@ -3242,12 +3252,24 @@ mod spread_tests {
fn spread_adjusted_samples_wraps_clear_in_transparent_guards() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Repeat, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder);
let (samples, span) = spread_adjusted_samples(
&gradient,
GradientSpread::Repeat,
GradientForm::Linear,
GradientInterpolation::SrgbGamma,
ClearGuardPlacement::SvgStopOrder,
);
assert_eq!(span, (0., 1.));
assert_eq!(samples, gradient.interpolated_samples());
assert_eq!(samples, gradient.interpolated_samples(GradientInterpolation::SrgbGamma));
// SVG guards share the range ends' exact offsets, ordered so the pad extension resolves to the transparent outer stops
let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder);
let (samples, span) = spread_adjusted_samples(
&gradient,
GradientSpread::Clear,
GradientForm::Linear,
GradientInterpolation::SrgbGamma,
ClearGuardPlacement::SvgStopOrder,
);
assert_eq!(span, (0., 1.));
assert_eq!(
samples,
@@ -3256,7 +3278,13 @@ mod spread_tests {
// Vello guards own the outermost ramp texels, with the visible range compressed inward to make room
let texel = 1. / (VELLO_GRADIENT_RAMP_TEXELS - 1.);
let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::VelloRampTexels);
let (samples, span) = spread_adjusted_samples(
&gradient,
GradientSpread::Clear,
GradientForm::Linear,
GradientInterpolation::SrgbGamma,
ClearGuardPlacement::VelloRampTexels,
);
assert_eq!(
samples,
vec![
@@ -3269,7 +3297,13 @@ mod spread_tests {
assert!(span.0 < 0. && span.1 > 1., "the geometry must stretch to compensate for the compressed stops: {span:?}");
// A radial keeps its stops and span anchored at zero, with no guard below the center
let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Radial, ClearGuardPlacement::VelloRampTexels);
let (samples, span) = spread_adjusted_samples(
&gradient,
GradientSpread::Clear,
GradientForm::Radial,
GradientInterpolation::SrgbGamma,
ClearGuardPlacement::VelloRampTexels,
);
assert_eq!(span.0, 0.);
assert_eq!(samples.first().unwrap(), &(0., Color::BLACK, None));
assert_eq!(samples.last().unwrap(), &(1., Color::TRANSPARENT, None));
@@ -3277,7 +3311,13 @@ mod spread_tests {
#[test]
fn spread_adjusted_samples_keeps_a_stopless_clear_gradient_black_inside_the_range() {
let (samples, _) = spread_adjusted_samples(&Gradient::from(Vec::new()), GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder);
let (samples, _) = spread_adjusted_samples(
&Gradient::from(Vec::new()),
GradientSpread::Clear,
GradientForm::Linear,
GradientInterpolation::SrgbGamma,
ClearGuardPlacement::SvgStopOrder,
);
let colors: Vec<Color> = samples.iter().map(|&(_, color, _)| color).collect();
assert_eq!(colors, vec![Color::TRANSPARENT, Color::BLACK, Color::BLACK, Color::TRANSPARENT]);
}

View File

@@ -1,4 +1,4 @@
use crate::markers::ATTR_GRADIENT_SPREAD;
use crate::markers::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPREAD};
use core_types::Color;
use core_types::color::SRGBA8;
use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List};
@@ -94,12 +94,14 @@ impl From<&GradientStops<SRGBA8>> for Gradient {
impl GradientStops<SRGBA8> {
/// CSS `linear-gradient(...)` string. Stops are emitted as `#rrggbbaa` hex (already gamma-encoded bytes).
pub fn to_css_linear_gradient(&self) -> String {
Gradient::from(self).to_css_linear_gradient()
pub fn to_css_linear_gradient(&self, gradient_interpolation: GradientInterpolation) -> String {
Gradient::from(self).to_css_linear_gradient(gradient_interpolation)
}
}
/// The serialized exchange form of a gradient: its stops, with whole-ramp settings as sibling fields serialized only when non-default.
/// The serialized exchange form of a gradient: its stops, with whole-ramp settings as sibling fields serialized
/// only when non-default. The interpolation is the exception: it always serializes, so its absence marks a ramp
/// from before the field existed, which deserializes as the gamma those documents rendered with.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -108,6 +110,9 @@ pub struct GradientRamp<C = Color> {
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientSpread::is_default"))]
#[cfg_attr(feature = "wasm", tsify(optional))]
pub gradient_spread: GradientSpread,
// TODO: Elide the default again (removing `legacy_gamma`) when switching to the new document format and Ctrl-C node serialization format
#[cfg_attr(feature = "serde", serde(default = "GradientInterpolation::legacy_gamma"))]
pub gradient_interpolation: GradientInterpolation,
}
unsafe impl<C: dyn_any::StaticTypeSized> dyn_any::StaticType for GradientRamp<C> {
@@ -119,6 +124,7 @@ impl<C> From<GradientStops<C>> for GradientRamp<C> {
Self {
stops,
gradient_spread: Default::default(),
gradient_interpolation: Default::default(),
}
}
}
@@ -128,6 +134,7 @@ impl From<&Gradient> for GradientRamp {
Self {
stops: gradient.into(),
gradient_spread: Default::default(),
gradient_interpolation: Default::default(),
}
}
}
@@ -158,6 +165,9 @@ impl From<GradientRamp> for Item<Gradient> {
if !ramp.gradient_spread.is_default() {
item.set_attribute(ATTR_GRADIENT_SPREAD, ramp.gradient_spread);
}
if !ramp.gradient_interpolation.is_default() {
item.set_attribute(ATTR_GRADIENT_INTERPOLATION, ramp.gradient_interpolation);
}
item
}
}
@@ -167,6 +177,7 @@ impl From<&Item<Gradient>> for GradientRamp {
Self {
stops: item.element().into(),
gradient_spread: item.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD),
gradient_interpolation: item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION),
}
}
}
@@ -193,6 +204,7 @@ impl From<&GradientRamp> for GradientRamp<SRGBA8> {
Self {
stops: ramp.into(),
gradient_spread: ramp.gradient_spread,
gradient_interpolation: ramp.gradient_interpolation,
}
}
}
@@ -202,6 +214,7 @@ impl From<&Gradient> for GradientRamp<SRGBA8> {
Self {
stops: gradient.into(),
gradient_spread: Default::default(),
gradient_interpolation: Default::default(),
}
}
}
@@ -210,6 +223,7 @@ impl From<&GradientRamp<SRGBA8>> for GradientRamp {
fn from(ramp: &GradientRamp<SRGBA8>) -> Self {
Self {
gradient_spread: ramp.gradient_spread,
gradient_interpolation: ramp.gradient_interpolation,
..Self::from(&ramp.stops)
}
}
@@ -260,6 +274,20 @@ fn apply_midpoint(t: f64, midpoint: f64) -> f64 {
}
}
/// Interpolates between two adjacent stops' colors at `t` across their interval, in the gradient's interpolation color space.
pub fn interpolate_stop_colors(color_a: Color, color_b: Color, t: f32, gradient_interpolation: GradientInterpolation) -> Color {
match gradient_interpolation {
GradientInterpolation::SrgbLinear => color_a.lerp(&color_b, t),
GradientInterpolation::SrgbGamma => color_a.lerp_gamma_srgb(&color_b, t),
}
}
/// The largest difference between two colors across their gamma sRGB channels, the 8-bit-adjacent measure that rendered output quantizes to.
fn max_gamma_channel_deviation(a: Color, b: Color) -> f64 {
let (a, b) = (a.to_gamma_srgb_channels(), b.to_gamma_srgb_channels());
(0..4).fold(0_f64, |max, i| max.max((a[i] - b[i]).abs() as f64))
}
#[derive(Debug, Clone, Copy)]
pub struct GradientStop {
pub position: f64,
@@ -634,6 +662,7 @@ impl Gradient {
if t >= a.position && t <= b.position {
let normalized_t = (t - a.position) / (b.position - a.position);
let adjusted_t = apply_midpoint(normalized_t, a.midpoint);
// Sampling deliberately stays in linear light; the ramp's interpolation space attribute only shapes what the renderers draw
return a.color.lerp(&b.color, adjusted_t as f32);
}
}
@@ -675,14 +704,14 @@ impl Gradient {
mapped
}
/// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves so the rendered gradient matches Graphite's interpolation rather than browser defaults.
pub fn to_css_linear_gradient(&self) -> String {
/// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves and interpolation color space so the rendered gradient matches Graphite's interpolation rather than browser defaults.
pub fn to_css_linear_gradient(&self, gradient_interpolation: GradientInterpolation) -> String {
if self.len() <= 1 {
let hex = self.color(0).map(|c| SRGBA8::from(c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
}
let pieces = self
.interpolated_samples()
.interpolated_samples(gradient_interpolation)
.into_iter()
.map(|(position, color, _)| {
let percent = ((position * 100.) * 1e2).round() / 1e2;
@@ -693,21 +722,33 @@ impl Gradient {
format!("linear-gradient(to right, {pieces})")
}
/// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves.
/// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves
/// and interpolation color space.
///
/// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding
/// midpoint for actual gradient stops, and `None` for synthesized midpoint-curve approximation samples.
/// midpoint for actual gradient stops, and `None` for synthesized curve approximation samples.
///
/// Interpolation is performed in sRGB gamma space (then lifted back to linear-light for output) because the downstream SVG/CSS
/// renderer interpolates between adjacent `<stop>` colors in gamma space; doing the subdivision math in the same space ensures
/// the chosen samples actually match the curve the browser will draw.
pub fn interpolated_samples(&self) -> Vec<(f64, Color, Option<f64>)> {
/// The downstream SVG/CSS and Vello renderers interpolate between adjacent emitted stops in gamma sRGB space, so the
/// subdivision emits enough samples that the gamma-drawn segments match the ramp's true curve: the midpoint bias, and
/// the interpolation color space when it is not gamma itself.
pub fn interpolated_samples(&self, gradient_interpolation: GradientInterpolation) -> Vec<(f64, Color, Option<f64>)> {
/// Controls accuracy vs. number of samples tradeoff.
/// 2/255 means the linear approximation will deviate by no more than 2 gradations of 8-bit color from the theoretically perfect curve with this midpoint bias.
const THRESHOLD: f64 = 2. / 255.;
#[allow(clippy::too_many_arguments)]
fn subdivide(left: f64, right: f64, midpoint: f64, pos_a: f64, pos_b: f64, color_a_gamma: [f32; 4], color_b_gamma: [f32; 4], result: &mut Vec<(f64, Color, Option<f64>)>, depth: u32) {
fn subdivide(
left: f64,
right: f64,
midpoint: f64,
pos_a: f64,
pos_b: f64,
color_a: Color,
color_b: Color,
gradient_interpolation: GradientInterpolation,
result: &mut Vec<(f64, Color, Option<f64>)>,
depth: u32,
) {
const MAX_DEPTH: u32 = 20;
if depth >= MAX_DEPTH {
return;
@@ -720,19 +761,24 @@ impl Gradient {
let y_right = apply_midpoint(right, midpoint);
let y_linear = (y_left + y_right) / 2.;
if (y_actual - y_linear).abs() > THRESHOLD {
subdivide(left, mid, midpoint, pos_a, pos_b, color_a_gamma, color_b_gamma, result, depth + 1);
// A sample is needed wherever the renderer's gamma segment between the flanking samples would stray
// from the ramp's true curve: from the midpoint bias, or from a non-gamma space's own curvature
let midpoint_deviates = (y_actual - y_linear).abs() > THRESHOLD;
let space_deviates = gradient_interpolation != GradientInterpolation::SrgbGamma && {
let color_target = interpolate_stop_colors(color_a, color_b, y_actual as f32, gradient_interpolation);
let color_left = interpolate_stop_colors(color_a, color_b, y_left as f32, gradient_interpolation);
let color_right = interpolate_stop_colors(color_a, color_b, y_right as f32, gradient_interpolation);
max_gamma_channel_deviation(color_target, color_left.lerp_gamma_srgb(&color_right, 0.5)) > THRESHOLD
};
if midpoint_deviates || space_deviates {
subdivide(left, mid, midpoint, pos_a, pos_b, color_a, color_b, gradient_interpolation, result, depth + 1);
let global_pos = pos_a + mid * (pos_b - pos_a);
let t = y_actual as f32;
let r = color_a_gamma[0] + (color_b_gamma[0] - color_a_gamma[0]) * t;
let g = color_a_gamma[1] + (color_b_gamma[1] - color_a_gamma[1]) * t;
let b = color_a_gamma[2] + (color_b_gamma[2] - color_a_gamma[2]) * t;
let a = color_a_gamma[3] + (color_b_gamma[3] - color_a_gamma[3]) * t;
let color = Color::from_gamma_srgb_channels(r, g, b, a);
let color = interpolate_stop_colors(color_a, color_b, y_actual as f32, gradient_interpolation);
result.push((global_pos, color, None));
subdivide(mid, right, midpoint, pos_a, pos_b, color_a_gamma, color_b_gamma, result, depth + 1);
subdivide(mid, right, midpoint, pos_a, pos_b, color_a, color_b, gradient_interpolation, result, depth + 1);
}
}
@@ -761,9 +807,9 @@ impl Gradient {
result.push((pos_a, color_a, Some(midpoint)));
}
// Only subdivide if midpoint deviates from linear (0.5)
if (midpoint - 0.5).abs() >= 1e-6 {
subdivide(0., 1., midpoint, pos_a, pos_b, color_a.to_gamma_srgb_channels(), color_b.to_gamma_srgb_channels(), &mut result, 0);
// Only subdivide if the midpoint deviates from linear (0.5) or a non-gamma space may curve away from the drawn gamma segment
if (midpoint - 0.5).abs() >= 1e-6 || gradient_interpolation != GradientInterpolation::SrgbGamma {
subdivide(0., 1., midpoint, pos_a, pos_b, color_a, color_b, gradient_interpolation, &mut result, 0);
}
// Add the end stop
@@ -825,6 +871,32 @@ impl GradientSpread {
}
}
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[widget(Dropdown)]
pub enum GradientInterpolation {
/// Blends stops in linear light, keeping transitions evenly bright.
#[default]
#[label("sRGB Linear")]
SrgbLinear,
/// Blends stops in gamma-encoded sRGB, the classic SVG and CSS look.
#[label("sRGB Gamma")]
SrgbGamma,
}
impl GradientInterpolation {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
// TODO: Remove when switching to the new document format and Ctrl-C node serialization format
fn legacy_gamma() -> Self {
Self::SrgbGamma
}
}
/// Rebuild the y-axis so its (parallel, perpendicular) components in the x-axis-aligned frame stay constant, both
/// rescaled by `|new_x| / |old_x|`. This holds the (x, y) parallelogram's aspect ratio and skew fixed across an endpoint
/// drag, so a radial ellipse stays the same shape (just rotated and resized) instead of distorting as x grows or shrinks.
@@ -958,6 +1030,129 @@ mod tests {
);
}
#[test]
fn gradient_interpolation_always_serializes_and_its_absence_reads_as_legacy_gamma() {
let default_interpolation = GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE]));
let json = serde_json::to_string(&default_interpolation).unwrap();
assert!(
json.contains(r#""gradient_interpolation":"SrgbLinear""#),
"the interpolation must serialize even at its default, marking the ramp as post-legacy: {json}"
);
assert_eq!(serde_json::from_str::<GradientRamp>(&json).unwrap(), default_interpolation);
let gamma = GradientRamp {
gradient_interpolation: GradientInterpolation::SrgbGamma,
..default_interpolation.clone()
};
let json = serde_json::to_string(&gamma).unwrap();
assert!(json.contains(r#""gradient_interpolation":"SrgbGamma""#), "a non-default interpolation must serialize: {json}");
assert_eq!(serde_json::from_str::<GradientRamp>(&json).unwrap(), gamma);
let legacy_json = json.replace(r#","gradient_interpolation":"SrgbGamma""#, "");
assert_eq!(
serde_json::from_str::<GradientRamp>(&legacy_json).unwrap(),
gamma,
"a ramp saved before the field existed should read as the gamma it rendered with: {legacy_json}"
);
}
#[test]
fn gradient_interpolation_round_trips_through_the_item_attribute() {
let ramp = GradientRamp {
gradient_interpolation: GradientInterpolation::SrgbGamma,
..GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE]))
};
let item = Item::<Gradient>::from(ramp.clone());
assert_eq!(
item.attribute_cloned_or_default::<GradientInterpolation>(ATTR_GRADIENT_INTERPOLATION),
GradientInterpolation::SrgbGamma,
"the runtime item should carry the interpolation as its attribute"
);
assert_eq!(GradientRamp::from(&item), ramp);
let linear = Item::<Gradient>::from(GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE])));
assert!(
linear.attribute::<GradientInterpolation>(ATTR_GRADIENT_INTERPOLATION).is_none(),
"the default Linear must stay absent rather than materialize"
);
}
#[test]
fn linear_interpolation_densifies_samples_where_gamma_segments_deviate() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
// Gamma needs no synthesized samples since the renderers already draw gamma segments
assert_eq!(gradient.interpolated_samples(GradientInterpolation::SrgbGamma).len(), 2);
// A linear black-to-white ramp curves away from any single gamma segment, so samples must densify,
// keeping the end stops in place and every synthesized color on the linear-light line
let samples = gradient.interpolated_samples(GradientInterpolation::SrgbLinear);
assert!(samples.len() > 2, "linear interpolation should synthesize samples, got {}", samples.len());
assert_eq!(samples.first().unwrap().0, 0.);
assert_eq!(samples.last().unwrap().0, 1.);
for &(position, color, _) in &samples {
assert!(
(color.r() as f64 - position).abs() < 1e-5,
"sample at {position} should sit on the linear-light line, got {}",
color.r()
);
}
// Identical end colors leave nothing to densify
let flat = Gradient::from(vec![Color::WHITE, Color::WHITE]);
assert_eq!(flat.interpolated_samples(GradientInterpolation::SrgbLinear).len(), 2);
}
#[test]
fn midpoint_bias_and_interpolation_space_compose_within_playback_tolerance() {
let color_pairs = [
(Color::BLACK, Color::WHITE),
(Color::RED, Color::WHITE),
(Color::from_rgbaf32_unchecked(0.9, 0.2, 0.05, 1.), Color::from_rgbaf32_unchecked(0.05, 0.3, 0.8, 0.5)),
];
// Sweep the midpoint against each space so its bias and the space's curvature also oppose each other,
// asserting the emitted samples' gamma playback tracks the composed midpoint-then-space theoretical curve
for gradient_interpolation in [GradientInterpolation::SrgbLinear, GradientInterpolation::SrgbGamma] {
for &(color_a, color_b) in &color_pairs {
for midpoint_step in 1..40 {
let midpoint = midpoint_step as f64 / 40.;
let mut gradient = Gradient::from(vec![color_a, color_b]);
gradient.set_midpoints(&[midpoint, 0.5]);
let samples = gradient.interpolated_samples(gradient_interpolation);
for probe in 0..=1000 {
let t = probe as f64 / 1000.;
let after = samples.iter().position(|&(position, ..)| position >= t).unwrap_or(samples.len() - 1);
let playback = if after == 0 {
samples[0].1
} else {
let (left_position, left_color, _) = samples[after - 1];
let (right_position, right_color, _) = samples[after];
let span = right_position - left_position;
if span < 1e-12 {
right_color
} else {
left_color.lerp_gamma_srgb(&right_color, ((t - left_position) / span) as f32)
}
};
let true_color = interpolate_stop_colors(color_a, color_b, apply_midpoint(t, midpoint) as f32, gradient_interpolation);
let deviation = max_gamma_channel_deviation(playback, true_color);
assert!(
deviation <= 4. / 255.,
"playback deviates {:.1}/255 at t={t} with midpoint {midpoint} in {gradient_interpolation:?} between {color_a:?} and {color_b:?}",
deviation * 255.
);
}
}
}
}
}
#[test]
fn clear_spread_evaluates_to_transparency_outside_the_unit_range() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
@@ -1009,7 +1204,7 @@ mod tests {
gradient.set_positions(&[1.5, 0.4, -0.5]);
assert_eq!(gradient.positions(), vec![1.5, 0.4, -0.5]);
let sample_positions: Vec<f64> = gradient.interpolated_samples().iter().map(|(position, ..)| *position).collect();
let sample_positions: Vec<f64> = gradient.interpolated_samples(GradientInterpolation::SrgbGamma).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.));
@@ -1023,7 +1218,7 @@ mod tests {
let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]);
gradient.set_positions(&[f64::INFINITY, f64::NEG_INFINITY]);
let sample_positions: Vec<f64> = gradient.interpolated_samples().iter().map(|(position, ..)| *position).collect();
let sample_positions: Vec<f64> = gradient.interpolated_samples(GradientInterpolation::SrgbGamma).iter().map(|(position, ..)| *position).collect();
assert_eq!(sample_positions, vec![0., 1.]);
assert_eq!(gradient.evaluate(0., Default::default()), Color::BLACK);
assert_eq!(gradient.evaluate(1., Default::default()), Color::WHITE);
@@ -1034,7 +1229,7 @@ mod tests {
let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK, Color::RED]);
gradient.set_positions(&[0., f64::NAN, 1.]);
let sample_positions: Vec<f64> = gradient.interpolated_samples().iter().map(|(position, ..)| *position).collect();
let sample_positions: Vec<f64> = gradient.interpolated_samples(GradientInterpolation::SrgbGamma).iter().map(|(position, ..)| *position).collect();
assert_eq!(sample_positions, vec![0., 1.]);
assert_eq!(gradient.evaluate(0.5, Default::default()), Color::WHITE.lerp(&Color::RED, 0.5));
@@ -1044,7 +1239,7 @@ mod tests {
// With every position NaN the gradient samples as stopless, painting solid black to signal the upstream bug
let mut gradient = Gradient::from(vec![Color::WHITE, Color::RED]);
gradient.set_positions(&[f64::NAN, f64::NAN]);
assert!(gradient.interpolated_samples().is_empty());
assert!(gradient.interpolated_samples(GradientInterpolation::SrgbGamma).is_empty());
assert_eq!(gradient.evaluate(0.5, Default::default()), Color::BLACK);
}
@@ -1053,7 +1248,7 @@ mod tests {
let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]);
gradient.set_positions(&[0.3, 1.]);
let samples = gradient.interpolated_samples();
let samples = gradient.interpolated_samples(GradientInterpolation::SrgbGamma);
assert_eq!(samples[0], (0.3, Color::WHITE, None), "renderers that need a flat lead-in before the first stop add it themselves");
}
@@ -1065,7 +1260,7 @@ mod tests {
gradient.set_midpoints(&[f64::NAN, f64::NAN]);
assert_eq!(gradient.evaluate(0.25, Default::default()), linear_result);
let no_nan_annotations = gradient
.interpolated_samples()
.interpolated_samples(GradientInterpolation::SrgbGamma)
.iter()
.all(|(position, _, midpoint)| position.is_finite() && !midpoint.is_some_and(|midpoint| midpoint.is_nan()));
assert!(no_nan_annotations, "NaN must not escape into rendered sample annotations");

View File

@@ -9,7 +9,7 @@ pub mod vector;
// Re-export commonly used types at the crate root
pub use core_types as gcore;
pub use gradient::{Gradient, GradientForm, GradientRamp, GradientSpread, GradientStop};
pub use gradient::{Gradient, GradientForm, GradientInterpolation, GradientRamp, GradientSpread, GradientStop};
pub use markers::{ATTR_EDITOR_CLICK_TARGET, ATTR_GRADIENT_FORM, ATTR_GRADIENT_SPREAD};
pub use math::{QuadExt, RectExt};
pub use subpath::Subpath;

View File

@@ -6,6 +6,8 @@ use core_types::attribute::Attribute;
core_types::attribute! {
/// Gradient's spread behavior past its endpoints (`Pad`, `Reflect`, `Repeat`, or `Clear`).
pub GradientSpread("gradient_spread"): crate::gradient::GradientSpread;
/// Gradient's `GradientInterpolation` (`SrgbLinear` or `SrgbGamma`), the color space its stops blend in.
pub GradientInterpolation("gradient_interpolation"): crate::gradient::GradientInterpolation;
/// Gradient's shape (`Linear` or `Radial`).
pub GradientForm("gradient_form"): crate::gradient::GradientForm;
/// Optional `Vector` that overrides the item's own geometry for click-target generation.
@@ -20,10 +22,12 @@ core_types::attribute! {
core_types::named_value! {
for crate::gradient::GradientSpread;
for crate::gradient::GradientForm;
for crate::gradient::GradientInterpolation;
}
pub const ATTR_GRADIENT_SPREAD: &str = GradientSpread::NAME;
pub const ATTR_GRADIENT_FORM: &str = GradientForm::NAME;
pub const ATTR_GRADIENT_INTERPOLATION: &str = GradientInterpolation::NAME;
pub const ATTR_EDITOR_CLICK_TARGET: &str = EditorClickTarget::NAME;
#[cfg(test)]

View File

@@ -76,7 +76,7 @@ impl FillChoice<SRGBA8> {
let hex = srgba.to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{hex})"))
}
Self::Gradient(ramp) => Some(ramp.stops.to_css_linear_gradient()),
Self::Gradient(ramp) => Some(ramp.stops.to_css_linear_gradient(ramp.gradient_interpolation)),
}
}
}

View File

@@ -11,7 +11,7 @@ use math_parser::value::{Number, Value};
use rand::{Rng, SeedableRng};
use std::ops::{Add, Mul, Rem, Sub};
use vector_types::Gradient;
use vector_types::markers::{GradientForm as GradientFormAttr, GradientSpread as GradientSpreadAttr};
use vector_types::markers::{GradientForm as GradientFormAttr, GradientInterpolation as GradientInterpolationAttr, GradientSpread as GradientSpreadAttr};
/// The struct that stores the context for the maths parser.
/// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs.
@@ -1214,12 +1214,18 @@ fn gradient_spread(_: impl Ctx, gradient: Gradient, gradient_spread: vector_type
(gradient, Attr(gradient_spread))
}
/// Sets the color space each gradient in the input list blends between its stops with: linear light or gamma-encoded sRGB.
#[node_macro::node(category("Gradient"))]
fn gradient_interpolation(_: impl Ctx, gradient: Gradient, gradient_interpolation: vector_types::GradientInterpolation) -> (Gradient, Attr<GradientInterpolationAttr>) {
(gradient, Attr(gradient_interpolation))
}
/// 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("Gradient"))]
fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: IList<f64>) -> Gradient {
let positions: Vec<f64> = positions.iter().collect();
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
}
@@ -1230,8 +1236,8 @@ fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: IList<f64>
///
/// 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("Gradient"))]
fn gradient_midpoints(_: impl Ctx, mut gradient: Gradient, midpoints: IList<f64>) -> Gradient {
let midpoints: Vec<f64> = midpoints.iter().collect();
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
}