mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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:
committed by
Dennis Kobert
parent
3dc0729690
commit
0e5b35d077
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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::{GradientSpread as GradientSpreadAttr, GradientForm as GradientFormAttr};
|
||||
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.));
|
||||
}
|
||||
@@ -509,8 +515,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
|
||||
@@ -2404,6 +2412,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 {
|
||||
@@ -2419,7 +2428,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 {
|
||||
@@ -2490,6 +2499,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);
|
||||
@@ -2499,7 +2509,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);
|
||||
@@ -3243,12 +3253,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,
|
||||
@@ -3257,7 +3279,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![
|
||||
@@ -3270,7 +3298,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));
|
||||
@@ -3278,7 +3312,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]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::markers::ATTR_GRADIENT_SPREAD;
|
||||
use core_types::Color;
|
||||
use core_types::color::SRGBA8;
|
||||
use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List};
|
||||
use crate::markers::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPREAD};
|
||||
use core_types::render_complexity::RenderComplexity;
|
||||
use dyn_any::DynAny;
|
||||
use glam::{DAffine2, DVec2};
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user