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-08-03 04:05:02 -07:00
committed by GitHub
parent e52a442504
commit 2f24459344
34 changed files with 1121 additions and 475 deletions

View File

@@ -25,7 +25,6 @@ use std::marker::PhantomData;
use std::str::FromStr;
pub use std::sync::Arc;
use text_nodes::Font;
use text_nodes::vector_types::GradientStop;
use vector::VectorModification;
pub struct TaggedValueTypeError;
@@ -83,7 +82,7 @@ macro_rules! tagged_value {
/// (Old documents stored a bare `TypeDescriptor` payload, routed to this shape by `deserialize_tagged_value_with_legacy_migration`.)
TypeDefault(Type),
/// Stored compactly as a `Vec<f64>`, materializes as `List<f64>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
#[serde(deserialize_with = "core_types::misc::migrate_to_f64_array")] // TODO: Eventually remove this migration document upgrade code
#[serde(deserialize_with = "core_types::misc::migrate_to_f64_array")] // TODO: Eventually remove this document upgrade code
#[serde(alias = "F64Table", alias = "VecF64", alias = "VecF32", alias = "F64Array4")]
F64Array(Vec<f64>),
/// Stored compactly as a `Vec<f64>` of dash lengths, materializes as an `Item<DashPattern>` at runtime via `to_dynany`/`to_any`.
@@ -92,16 +91,15 @@ macro_rules! tagged_value {
BoxCorners(Vec<f64>),
/// A plain, always-present color. Aliases recover legacy on-disk shapes; a legacy `null` payload (the old "no color")
/// is routed to [`TaggedValue::no_paint`] by `deserialize_tagged_value_with_legacy_migration`.
#[serde(deserialize_with = "core_types::misc::migrate_to_color")] // TODO: Eventually remove this migration document upgrade code
#[serde(deserialize_with = "core_types::misc::migrate_to_color")] // TODO: Eventually remove this document upgrade code
#[serde(alias = "ColorTable", alias = "OptionalColor", alias = "ColorNotInTable")]
Color(Color),
/// Stored compactly as a `Gradient`, materializing as an `Item<Gradient>` at runtime. Aliases recover legacy on-disk shapes.
/// Stored as the `{ color, position?, midpoint? }` stops struct, materializing as an `Item<Gradient>` at runtime. Aliases recover legacy on-disk shapes.
/// (Old documents that stored a full `Gradient` struct under this same `"Gradient"` tag are routed to `LegacyGradient` by `deserialize_tagged_value_with_legacy_migration`.)
#[serde(deserialize_with = "graphic_types::vector_types::gradient::migrate_to_gradient")] // TODO: Eventually remove this migration document upgrade code
#[serde(alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")]
Gradient(Gradient),
/// Stored compactly as a `Vec<BrushStroke>`, materializes as the single-value `Item<BrushTrace>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
#[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this migration document upgrade code
#[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this document upgrade code
#[serde(alias = "BrushStrokeTable")]
BrushStrokes(Vec<BrushStroke>),
// =======================
@@ -637,29 +635,13 @@ impl TaggedValue {
fn to_gradient(input: &str) -> Option<Gradient> {
// String syntax: (e.g. "000000ff, ff0000ff")
let stops = input.split(',').filter_map(|s| to_color(s.trim())).collect::<Vec<_>>();
if stops.len() == 1 {
Some(Gradient::new(vec![
GradientStop {
position: 0.,
midpoint: 0.5,
color: stops[0],
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: stops[0],
},
]))
} else if stops.len() >= 2 {
let step = 1. / (stops.len() - 1) as f64;
Some(Gradient::new(stops.into_iter().enumerate().map(|(i, color)| GradientStop {
position: i as f64 * step,
midpoint: 0.5,
color,
})))
} else {
log::error!("Invalid default value gradient string: {input}");
None
match stops.len() {
0 => {
log::error!("Invalid default value gradient string: {input}");
None
}
1 => Some(Gradient::from(vec![stops[0], stops[0]])),
_ => Some(Gradient::from(stops)),
}
}
@@ -755,10 +737,11 @@ impl TaggedValue {
/// - non-empty → `TaggedValue::VectorModification(<built from first element>)` (the document_migration's Path pass disambiguates this between SVG-import legacy and a discardable modern baked value via the input's `exposed` flag)
/// - empty → `TaggedValue::TypeDefault(list!(Vector))`
/// - `FillChoice` → `TaggedValue::Color` (solid), `TaggedValue::Gradient` (gradient), or `TaggedValue::no_paint()` (none)
/// - `Gradient` (or alias `GradientTable`/`GradientPositions`/`GradientStops`) → `TaggedValue::LegacyGradient` (ancient full struct) or `TaggedValue::Gradient` (stops shapes, unwrapped from the legacy table form)
/// - `TypeDefault` with the old bare-`TypeDescriptor` payload → the same variant wrapping a `Type` (name-encoded `List` normalized to structural)
///
/// All other tags (including ones with the modern shape) fall through to the standard derived `Deserialize` for `TaggedValue`.
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
#[cfg(feature = "loading")]
pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<MemoHash<TaggedValue>, D::Error> {
use serde::Deserialize;
@@ -822,11 +805,30 @@ pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserialize
}
return Ok(MemoHash::new(TaggedValue::no_paint()));
}
// The `Gradient` tag was reused: it used to carry a full `Gradient` struct (now `LegacyGradient`), and now carries an `Option<Gradient>`.
// Disambiguate by payload shape: a Gradient struct has `start`/`end` keys; a `Gradient` has none of those (it has `position`/`midpoint`/`color`).
"Gradient" if content.as_object().is_some_and(|c| c.contains_key("start") && c.contains_key("end")) => {
let gradient: graphic_types::migrations::legacy::LegacyGradient = serde_json::from_value(content.clone()).map_err(serde::de::Error::custom)?;
return Ok(MemoHash::new(TaggedValue::LegacyGradient(gradient)));
// The gradient tags carried several shapes over time, disambiguated here: the ancient full struct (`start`/`end` keys) becomes `LegacyGradient`,
// while the current stops struct, the old tuple list, and the legacy one-element table wrapper all parse as the stops value directly
"Gradient" | "GradientTable" | "GradientPositions" | "GradientStops" => {
let table_element = content
.as_object()
.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
if let Some(array) = table_element
&& array.is_empty()
{
return Ok(MemoHash::new(TaggedValue::Gradient(Gradient::default())));
}
let payload = table_element.and_then(|array| array.first()).unwrap_or(content);
if payload.as_object().is_some_and(|c| c.contains_key("start") && c.contains_key("end")) {
let gradient: graphic_types::migrations::legacy::LegacyGradient = serde_json::from_value(payload.clone()).map_err(serde::de::Error::custom)?;
return Ok(MemoHash::new(TaggedValue::LegacyGradient(gradient)));
}
let gradient: Gradient = serde_json::from_value(payload.clone()).map_err(serde::de::Error::custom)?;
return Ok(MemoHash::new(TaggedValue::Gradient(gradient)));
}
_ => {}
}

View File

@@ -346,6 +346,32 @@ fn position_value_converts_through_the_vector_input_adapter() {
assert!(result.is_some(), "The position should arrive as an Item<Vector> single-anchor path");
}
// The 'Colors to Gradient' node turns an entire `List<Color>` wire into one gradient with those colors as its stops
#[test]
fn color_list_wraps_through_the_colors_to_gradient_node() {
let color_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::Color(graphene_std::Color::WHITE).into()), vec![NodeId(0)]);
let mut raise_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]);
raise_node.identifier = ProtoNodeIdentifier::new("graphene_core::ops::ItemToListNode<Color>");
let mut colors_to_gradient_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(1)]), vec![NodeId(2)]);
colors_to_gradient_node.identifier = ProtoNodeIdentifier::new("graphic_nodes::graphic::ColorsToGradientNode");
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(2),
nodes: vec![(NodeId(0), color_node), (NodeId(1), raise_node), (NodeId(2), colors_to_gradient_node)],
};
let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY);
typing_context.update(&network).expect("A List<Color> wire should resolve the node's List<Color> implementation");
let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The node constructor should instantiate");
let context: Context = None;
let result: Option<Item<graphene_std::vector::Gradient>> = futures::executor::block_on(tree.eval(NodeId(2), context));
let gradient = result.expect("The color list should arrive wrapped as a gradient");
assert_eq!(gradient.element().len(), 1, "The single color should become the gradient's one stop");
}
// A scalar wire feeding a `DVec2` connector splats into both axes through the input adapter's `Convert` row
#[test]
fn number_value_splats_through_the_vec2_input_adapter() {

View File

@@ -55,10 +55,17 @@ pub const ATTR_DIMENSIONS: &str = "dimensions";
pub const ATTR_BACKGROUND: &str = "background";
/// `bool` for whether an artboard clips content to its bounds.
pub const ATTR_CLIP: &str = "clip";
// TODO: Consider adding "spread_method_left" and "spread_method_right" override attributes to allow setting different spread methods on each side of a gradient
/// Gradient's `GradientSpreadMethod` (`Pad`, `Reflect`, or `Repeat`).
pub const ATTR_SPREAD_METHOD: &str = "spread_method";
/// Gradient's `GradientType` (`Linear` or `Radial`).
pub const ATTR_GRADIENT_TYPE: &str = "gradient_type";
/// Gradient stop's `f64` position from 0 to 1 along the gradient, on the `List<Color>` inside a `Gradient`.
/// When the attribute is absent, stops distribute evenly across the 0 to 1 range.
pub const ATTR_POSITION: &str = "position";
/// Gradient stop's `f64` midpoint (implicit default `0.5`, linear), a factor from 0 to 1 across the distance
/// to the next stop, on the `List<Color>` inside a `Gradient`. The final stop's midpoint is ignored.
pub const ATTR_MIDPOINT: &str = "midpoint";
/// Vector graphics object's filled area paint, of type List<T> where T is any graphic type.
pub const ATTR_FILL: &str = "fill";
/// Vector graphics object's stroke paint, of type List<T> where T is any graphic type.
@@ -73,7 +80,8 @@ pub const ATTR_LINE_HEIGHT: &str = "line_height";
pub const ATTR_LETTER_SPACING: &str = "letter_spacing";
/// Text item's maximum line-wrap width in document-space units (`Option<f64>`, implicit default `None`).
pub const ATTR_MAX_WIDTH: &str = "max_width";
/// Text item's maximum block height in document-space units, past which lines are not drawn (`Option<f64>`, implicit default `None`).
/// Text item's maximum block height in document-space units, past which lines are not drawn
/// (`Option<f64>`, implicit default `None`).
pub const ATTR_MAX_HEIGHT: &str = "max_height";
/// Text item's faux-italic letter tilt angle in degrees (`f64`, implicit default `0.`).
pub const ATTR_LETTER_TILT: &str = "letter_tilt";
@@ -136,6 +144,7 @@ unsafe impl<T: StaticTypeSized> StaticType for Bundle<T> {
fn implicit_default_value(key: &str) -> Option<Box<dyn AnyAttributeValue>> {
match key {
ATTR_OPACITY | ATTR_OPACITY_FILL => Some(Box::new(1_f64)),
ATTR_MIDPOINT => Some(Box::new(0.5_f64)),
_ => None,
}
}

View File

@@ -89,7 +89,7 @@ struct LegacyTable<T> {
element: Vec<T>,
}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
pub fn migrate_to_color<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<no_std_types::color::Color, D::Error> {
use no_std_types::color::Color;
use serde::Deserialize;
@@ -107,7 +107,7 @@ pub fn migrate_to_color<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Re
})
}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<f64>, D::Error> {
use serde::Deserialize;

View File

@@ -1,4 +1,4 @@
use crate::{ContextFeature, Node, NodeIO, NodeIOTypes, ProtoNodeIdentifier, Type, WasmNotSend};
use crate::{Color, ContextFeature, Node, NodeIO, NodeIOTypes, ProtoNodeIdentifier, Type, WasmNotSend};
use dyn_any::{DynAny, StaticType};
pub use no_std_types::registry::types;
use std::collections::HashMap;
@@ -29,6 +29,8 @@ pub struct FieldMetadata {
pub exposed: bool,
pub widget_override: RegistryWidgetOverride,
pub value_source: RegistryValueSource,
/// The default expression's colors, resolved by the macro when the expression consists solely of `Color::*` constants.
pub default_colors: Option<&'static [Color]>,
pub default_type: Option<Type>,
/// The slider's suggested extent, from `#[soft(a..b)]`. Typed values may exceed it.
pub number_soft_min: Option<f64>,

View File

@@ -14,7 +14,7 @@ pub mod migrations {
use crate::Vector;
// Storing legacy structs that are only used in document migration.
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
pub mod legacy {
use core_types::Color;
use dyn_any::DynAny;
@@ -113,7 +113,7 @@ pub mod migrations {
}
}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
/// Returns the first `Vector` recovered from any of the legacy on-disk shapes (the legacy `VectorData` flat struct, a single `Vector`, or any of the historical `List<Vector>` variants).
pub fn migrate_to_optional_vector<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<Vector>, D::Error> {
use serde::Deserialize;

View File

@@ -273,7 +273,7 @@ pub struct Color {
// `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap<CacheHashWrapper<Image<Color>>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper.
impl Eq for Color {}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
#[cfg(feature = "std")]
impl serde::Serialize for Color {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
@@ -290,7 +290,7 @@ impl serde::Serialize for Color {
}
}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
#[cfg(feature = "std")]
impl<'de> serde::Deserialize<'de> for Color {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
@@ -413,6 +413,7 @@ impl Color {
pub const YELLOW: Color = Color::from_rgbf32_unchecked(1., 1., 0.);
pub const CYAN: Color = Color::from_rgbf32_unchecked(0., 1., 1.);
pub const MAGENTA: Color = Color::from_rgbf32_unchecked(1., 0., 1.);
pub const MIDDLE_GRAY: Color = Color::from_rgbf32_unchecked(0.5, 0.5, 0.5);
pub const TRANSPARENT: Color = Self {
red: 0.,
green: 0.,

View File

@@ -112,6 +112,11 @@ impl RenderExt for List<Gradient> {
stop.push_str(" />")
}
// A gradient with no stops paints as solid black, matching `Gradient::evaluate` (a stopless def would otherwise render as no paint per the SVG spec)
if stop.is_empty() {
stop.push_str(r##"<stop stop-color="#000000" />"##);
}
// Need to cancel out the element's transform as it is already applied to the path itself.
let element_transform_inverse = if transform_is_invertible(element_transform) {
element_transform.inverse()

View File

@@ -394,6 +394,31 @@ pub(crate) fn gradient_placement(transform: DAffine2, gradient_type: GradientTyp
}
}
/// Converts a gradient's renderer samples to peniko color stops, duplicating an off-zero first stop at position 0 since Vello ignores the first stop's position and always treats it as 0.
fn peniko_color_stops(gradient: &Gradient) -> peniko::ColorStops {
let mut peniko_stops = peniko::ColorStops::new();
for (position, color, _) in gradient.interpolated_samples() {
let color = peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color());
if peniko_stops.is_empty() && position > 0. {
peniko_stops.push(peniko::ColorStop { offset: 0., color });
}
peniko_stops.push(peniko::ColorStop { offset: position as f32, color });
}
// A gradient with no stops paints as solid black, matching `Gradient::evaluate`
if peniko_stops.is_empty() {
peniko_stops.push(peniko::ColorStop {
offset: 0.,
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(Color::BLACK).to_peniko_color()),
});
}
peniko_stops
}
fn create_peniko_gradient_brush(gradient_list: &List<Gradient>, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
let stops = gradient_list.element(0)?;
@@ -401,13 +426,7 @@ fn create_peniko_gradient_brush(gradient_list: &List<Gradient>, multiplied_trans
let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
let spread_method: GradientSpreadMethod = gradient_list.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0);
let mut peniko_stops = peniko::ColorStops::new();
for (position, color, _) in stops.interpolated_samples() {
peniko_stops.push(peniko::ColorStop {
offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
});
}
let peniko_stops = peniko_color_stops(stops);
// The unit gradient is placed by the desheared frame so a non-uniform transform produces the intended ellipse
let (start, end, gradient_to_device) = (DVec2::ZERO, DVec2::X, gradient_placement(multiplied_transform * gradient_transform, gradient_type));
@@ -2172,13 +2191,7 @@ impl Render for List<Gradient> {
let blend_mode = blend_mode_attr.to_peniko();
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let mut stops: peniko::ColorStops = peniko::ColorStops::new();
for (position, color, _) in gradient.interpolated_samples() {
stops.push(peniko::ColorStop {
offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
})
}
let stops = peniko_color_stops(gradient);
let extend = match spread_method {
GradientSpreadMethod::Pad => peniko::Extend::Pad,

View File

@@ -36,3 +36,7 @@ serde = { workspace = true, optional = true }
tsify = { workspace = true, optional = true }
wasm-bindgen = { workspace = true, optional = true }
fixedbitset = "0.5.7"
[dev-dependencies]
# Workspace dependencies
serde_json = { workspace = true }

View File

@@ -1,5 +1,6 @@
use core_types::Color;
use core_types::color::SRGBA8;
use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List};
use core_types::render_complexity::RenderComplexity;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
@@ -14,116 +15,127 @@ pub enum GradientType {
Radial,
}
// TODO: Someday we could switch this to a Box[T] to avoid over-allocation
/// A list of colors (linear, unassociated alpha) associated with positions (in the range 0 to 1) along a gradient.
///
/// Not exposed via Tsify; use [`GradientUI`] at the JS boundary.
#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Gradient {
/// The position of this stop, a factor from 0-1 along the length of the full gradient.
pub position: Vec<f64>,
/// The midpoint to the right of this stop, a factor from 0-1 along the distance to the next stop. The final stop's midpoint is ignored.
pub midpoint: Vec<f64>,
/// The color at this stop.
pub color: Vec<Color>,
}
/// A gradient's stops: a list of colors (linear, unassociated alpha) whose optional `position` and `midpoint`
/// attributes place each stop along the 0 to 1 range. Stops lacking the `position` attribute distribute evenly,
/// and stops lacking the `midpoint` attribute interpolate linearly (`0.5`).
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
pub struct Gradient(List<Color>);
/// JS-boundary version of [`Gradient`] where stop colors are [`SRGBA8`] byte triples instead of linear-light [`Color`].
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Debug, Clone, PartialEq, Default, DynAny)]
/// A gradient's per-stop parallel arrays, generic over color format: `GradientStops<Color>` is the document serialization
/// of `TaggedValue::Gradient`, while `GradientStops<SRGBA8>` is the JS-boundary shape used by the color picker UI.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GradientUI {
pub position: Vec<f64>,
pub midpoint: Vec<f64>,
pub color: Vec<SRGBA8>,
pub struct GradientStops<C> {
pub color: Vec<C>,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
#[cfg_attr(feature = "wasm", tsify(optional))]
pub position: Option<Vec<f64>>,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
#[cfg_attr(feature = "wasm", tsify(optional))]
pub midpoint: Option<Vec<f64>>,
}
impl From<&Gradient> for GradientUI {
fn from(s: &Gradient) -> Self {
unsafe impl<C: dyn_any::StaticTypeSized> dyn_any::StaticType for GradientStops<C> {
type Static = GradientStops<C::Static>;
}
impl From<&Gradient> for GradientStops<Color> {
fn from(gradient: &Gradient) -> Self {
Self {
position: s.position.clone(),
midpoint: s.midpoint.clone(),
color: s.color.iter().map(|c| SRGBA8::from(*c)).collect(),
position: gradient.position_attribute(),
midpoint: gradient.midpoint_attribute(),
color: gradient.0.iter_element_values().copied().collect(),
}
}
}
impl From<&GradientUI> for Gradient {
fn from(s: &GradientUI) -> Self {
impl From<&Gradient> for GradientStops<SRGBA8> {
fn from(gradient: &Gradient) -> Self {
Self {
position: s.position.clone(),
midpoint: s.midpoint.clone(),
color: s.color.iter().map(|c| Color::from(*c)).collect(),
position: gradient.position_attribute(),
midpoint: gradient.midpoint_attribute(),
color: gradient.0.iter_element_values().map(|&color| SRGBA8::from(color)).collect(),
}
}
}
impl GradientUI {
// The document path: faithful (no elision) so serialization stays a bijection under round-trip checks
impl From<GradientStops<Color>> for Gradient {
fn from(stops: GradientStops<Color>) -> Self {
let mut gradient = Gradient::from(stops.color);
if let Some(position) = &stops.position {
gradient.set_positions(position);
}
if let Some(midpoint) = &stops.midpoint {
gradient.set_midpoints(midpoint);
}
gradient
}
}
// Color picker round-trip: attributes that merely restate the defaults are elided to keep the canonical absence-as-default form
impl From<&GradientStops<SRGBA8>> for Gradient {
fn from(stops: &GradientStops<SRGBA8>) -> Self {
let mut gradient = Gradient::from(stops.color.iter().map(|&color| Color::from(color)).collect::<Vec<_>>());
if let Some(position) = &stops.position {
gradient.set_positions(position);
}
if let Some(midpoint) = &stops.midpoint {
gradient.set_midpoints(midpoint);
}
gradient.elide_default_attributes();
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 {
if self.position.len() <= 1 {
let hex = self.color.first().map(|c| c.to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
}
// Sample via the midpoint-aware subdivision used for SVG/Vello stops so browser interpolation matches
let stops: Gradient = self.into();
let pieces = stops
.interpolated_samples()
.into_iter()
.map(|(position, color, _)| {
let percent = ((position * 100.) * 1e2).round() / 1e2;
let hex = SRGBA8::from(color).to_rgba_hex();
format!("#{hex} {percent}%")
})
.collect::<Vec<_>>()
.join(", ");
format!("linear-gradient(to right, {pieces})")
Gradient::from(self).to_css_linear_gradient()
}
}
// TODO: Eventually remove this migration document upgrade code
#[cfg(feature = "serde")]
impl serde::Serialize for Gradient {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
GradientStops::<Color>::from(self).serialize(serializer)
}
}
// TODO: Eventually remove this document upgrade code
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Gradient {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
struct NewFormat {
position: Vec<f64>,
midpoint: Vec<f64>,
color: Vec<Color>,
}
#[derive(serde::Deserialize)]
#[cfg_attr(feature = "serde", serde(untagged))]
#[serde(untagged)]
enum GradientStopsFormat {
New(NewFormat),
Old(Vec<(f64, Color)>),
Struct(GradientStops<Color>),
Tuples(Vec<(f64, Color)>),
}
Ok(match GradientStopsFormat::deserialize(deserializer)? {
GradientStopsFormat::New(new) => Self {
position: new.position,
midpoint: new.midpoint,
color: new.color,
},
GradientStopsFormat::Old(stops) => {
let count = stops.len();
Self {
position: stops.iter().map(|(p, _)| *p).collect(),
midpoint: vec![0.5; count],
color: stops.into_iter().map(|(_, c)| c).collect(),
}
GradientStopsFormat::Struct(stops) => Gradient::from(stops),
GradientStopsFormat::Tuples(stops) => {
let position: Vec<f64> = stops.iter().map(|(p, _)| *p).collect();
let mut gradient = Gradient::from(stops.into_iter().map(|(_, c)| c).collect::<Vec<_>>());
gradient.set_positions(&position);
gradient.elide_default_attributes();
gradient
}
})
}
}
impl Default for Gradient {
fn default() -> Self {
Self {
position: vec![0., 1.],
midpoint: vec![0.5, 0.5],
color: vec![Color::BLACK, Color::WHITE],
}
impl From<List<Color>> for Gradient {
fn from(colors: List<Color>) -> Self {
Self(colors)
}
}
impl From<Vec<Color>> for Gradient {
fn from(colors: Vec<Color>) -> Self {
Self(colors.into_iter().map(Item::new_from_element).collect())
}
}
@@ -133,14 +145,18 @@ impl RenderComplexity for Gradient {
}
}
/// The effective midpoint domain shared by sampling and rendering: NaN reads as the linear default, and extremes are bounded to `0.01..=0.99` so curves stay finite and cheap to subdivide.
fn sanitized_midpoint(midpoint: f64) -> f64 {
if midpoint.is_nan() { 0.5 } else { midpoint.clamp(0.01, 0.99) }
}
/// Apply the midpoint curve to a normalized parameter `t` (0 to 1) given a `midpoint` (0 to 1, where 0.5 is linear).
fn apply_midpoint(t: f64, midpoint: f64) -> f64 {
let midpoint = sanitized_midpoint(midpoint);
if (midpoint - 0.5).abs() < 1e-6 {
return t;
}
let midpoint = midpoint.clamp(f64::EPSILON, 1. - f64::EPSILON);
if midpoint < 0.5 {
let q = -1. / (1. - midpoint).log2();
1. - (1. - t).powf(q)
@@ -162,25 +178,21 @@ pub struct GradientStopsIter<'a> {
index: usize,
}
impl<'a> Iterator for GradientStopsIter<'a> {
impl Iterator for GradientStopsIter<'_> {
type Item = GradientStop;
fn next(&mut self) -> Option<Self::Item> {
if self.index >= self.stops.position.len() {
return None;
}
let stop = GradientStop {
position: self.stops.position[self.index],
midpoint: self.stops.midpoint[self.index],
color: self.stops.color[self.index],
position: self.stops.position(self.index),
midpoint: self.stops.midpoint(self.index),
color: self.stops.color(self.index)?,
};
self.index += 1;
Some(stop)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.stops.position.len() - self.index;
let remaining = self.stops.len().saturating_sub(self.index);
(remaining, Some(remaining))
}
}
@@ -201,63 +213,215 @@ impl IntoIterator for Gradient {
type IntoIter = std::vec::IntoIter<GradientStop>;
fn into_iter(self) -> Self::IntoIter {
self.position
.into_iter()
.zip(self.midpoint)
.zip(self.color)
.map(|((position, midpoint), color)| GradientStop { position, midpoint, color })
.collect::<Vec<_>>()
.into_iter()
self.iter().collect::<Vec<_>>().into_iter()
}
}
/// The fallback position of the gradient stop at `index` when no `position` attribute exists, where all `count` stops are spaced evenly from 0 to 1.
fn even_position(index: usize, count: usize) -> f64 {
if count <= 1 { 0. } else { index as f64 / (count - 1) as f64 }
}
impl Gradient {
pub fn new(stops: impl IntoIterator<Item = GradientStop>) -> Self {
let mut position = Vec::new();
let mut midpoint = Vec::new();
let mut color = Vec::new();
let stops: Vec<GradientStop> = stops.into_iter().collect();
let mut list: List<Color> = stops.iter().map(|stop| Item::new_from_element(stop.color)).collect();
for stop in stops {
position.push(stop.position);
midpoint.push(stop.midpoint);
color.push(stop.color);
for (index, stop) in stops.iter().enumerate() {
list.set_attribute(ATTR_POSITION, index, stop.position);
list.set_attribute(ATTR_MIDPOINT, index, stop.midpoint);
}
Self { position, midpoint, color }
Self(list)
}
pub fn black_to_white() -> Self {
Self::from(vec![Color::BLACK, Color::WHITE])
}
pub fn as_color_list(&self) -> &List<Color> {
&self.0
}
pub fn into_color_list(self) -> List<Color> {
self.0
}
pub fn len(&self) -> usize {
self.position.len()
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.position.is_empty()
self.0.is_empty()
}
pub fn iter(&self) -> GradientStopsIter<'_> {
self.into_iter()
}
/// The color of the stop at the given index, if in bounds.
pub fn color(&self, index: usize) -> Option<Color> {
self.0.element(index).copied()
}
/// The effective position of the stop at the given index: its `position` attribute value, or its share of an even distribution when the attribute is absent.
pub fn position(&self, index: usize) -> f64 {
self.0.attribute::<f64>(ATTR_POSITION, index).copied().unwrap_or_else(|| even_position(index, self.len()))
}
/// The effective midpoint of the stop at the given index: its `midpoint` attribute value, or the linear interpolation default of `0.5` when the attribute is absent.
pub fn midpoint(&self, index: usize) -> f64 {
self.0.attribute::<f64>(ATTR_MIDPOINT, index).copied().unwrap_or(0.5)
}
/// The effective positions of all stops.
pub fn positions(&self) -> Vec<f64> {
(0..self.len()).map(|index| self.position(index)).collect()
}
/// The effective midpoints of all stops.
pub fn midpoints(&self) -> Vec<f64> {
(0..self.len()).map(|index| self.midpoint(index)).collect()
}
/// Whether the `position` attribute is explicitly present rather than falling back to the even distribution.
pub fn has_position_attribute(&self) -> bool {
self.0.iter_attribute_values::<f64>(ATTR_POSITION).is_some()
}
/// Whether the `midpoint` attribute is explicitly present rather than falling back to the linear interpolation default.
pub fn has_midpoint_attribute(&self) -> bool {
self.0.iter_attribute_values::<f64>(ATTR_MIDPOINT).is_some()
}
/// The `position` attribute's values when present, or `None` when the stops fall back to the even distribution.
fn position_attribute(&self) -> Option<Vec<f64>> {
self.0.iter_attribute_values::<f64>(ATTR_POSITION).map(|values| values.copied().collect())
}
/// The `midpoint` attribute's values when present, or `None` when the stops fall back to the linear interpolation default.
fn midpoint_attribute(&self) -> Option<Vec<f64>> {
self.0.iter_attribute_values::<f64>(ATTR_MIDPOINT).map(|values| values.copied().collect())
}
/// The `position` attribute when present and meaningfully different from the even distribution, which is the form worth persisting in the graph.
pub fn nondefault_positions(&self) -> Option<Vec<f64>> {
let positions = self.position_attribute()?;
let count = self.len();
positions
.iter()
.enumerate()
.any(|(index, &position)| !position.is_finite() || (position - even_position(index, count)).abs() > 1e-6)
.then_some(positions)
}
/// The `midpoint` attribute when present and meaningfully different from the linear interpolation default of `0.5`.
pub fn nondefault_midpoints(&self) -> Option<Vec<f64>> {
let midpoints = self.midpoint_attribute()?;
midpoints.iter().any(|&midpoint| (midpoint - 0.5).abs() > 1e-6).then_some(midpoints)
}
/// Removes the `position`/`midpoint` attributes when they merely restate the defaults, restoring the canonical absence-as-default form.
pub fn elide_default_attributes(&mut self) {
if self.has_position_attribute() && self.nondefault_positions().is_none() {
self.0.remove_attribute(ATTR_POSITION);
}
if self.has_midpoint_attribute() && self.nondefault_midpoints().is_none() {
self.0.remove_attribute(ATTR_MIDPOINT);
}
}
/// Writes the whole `position` attribute from the effective values, since the even-distribution default is index-dependent and can't be produced by cell-wise padding.
fn materialize_default_positions(&mut self) {
if self.has_position_attribute() {
return;
}
let count = self.len();
for index in 0..count {
self.0.set_attribute(ATTR_POSITION, index, even_position(index, count));
}
}
/// Replaces the color of the stop at `index`, if it exists.
pub fn set_color(&mut self, index: usize, color: Color) {
if let Some(element) = self.0.element_mut(index) {
*element = color;
}
}
/// Sets the position of the stop at `index`, if it exists, materializing the whole `position` attribute so the other stops keep their effective placements.
pub fn set_position(&mut self, index: usize, position: f64) {
if index >= self.len() {
return;
}
self.materialize_default_positions();
self.0.set_attribute(ATTR_POSITION, index, position);
}
/// Sets the midpoint of the stop at `index`, if it exists.
pub fn set_midpoint(&mut self, index: usize, midpoint: f64) {
if index >= self.len() {
return;
}
self.0.set_attribute(ATTR_MIDPOINT, index, midpoint);
}
/// Replaces the `position` attribute with the given values, padding with the final value if fewer than the stop count and ignoring any extras.
/// An empty list removes the attribute, restoring even distribution.
pub fn set_positions(&mut self, positions: &[f64]) {
let Some(&last) = positions.last() else {
self.0.remove_attribute(ATTR_POSITION);
return;
};
for index in 0..self.len() {
self.0.set_attribute(ATTR_POSITION, index, positions.get(index).copied().unwrap_or(last));
}
}
/// Replaces the `midpoint` attribute with the given values, padding with the final value if fewer than the stop count and ignoring any extras.
/// An empty list removes the attribute, restoring the linear interpolation default of `0.5` for every stop.
pub fn set_midpoints(&mut self, midpoints: &[f64]) {
let Some(&last) = midpoints.last() else {
self.0.remove_attribute(ATTR_MIDPOINT);
return;
};
for index in 0..self.len() {
self.0.set_attribute(ATTR_MIDPOINT, index, midpoints.get(index).copied().unwrap_or(last));
}
}
/// Rebuilds the stop list from the given stop indices, preserving every attribute.
fn reordered(&self, indices: impl IntoIterator<Item = usize>) -> List<Color> {
let mut list = List::new();
for index in indices {
if let Some(item) = self.0.clone_item(index) {
list.push(item);
}
}
list
}
/// Remove a stop at the given index.
pub fn remove(&mut self, index: usize) {
self.position.remove(index);
self.midpoint.remove(index);
self.color.remove(index);
self.0 = self.reordered((0..self.len()).filter(|&i| i != index));
}
/// Remove and return the last stop's color, or `None` if empty.
pub fn pop(&mut self) -> Option<Color> {
self.position.pop();
self.midpoint.pop();
self.color.pop()
let color = self.color(self.len().checked_sub(1)?);
self.0 = self.reordered(0..self.len() - 1);
color
}
/// Move the stop at `index` to a new position, re-sorting the stops by position. Returns the new index of the moved stop.
pub fn move_stop(&mut self, index: usize, position: f64) -> usize {
if index >= self.position.len() {
if index >= self.len() {
return index;
}
self.position[index] = position;
self.set_position(index, position);
self.sort_returning_new_index(index)
}
@@ -265,66 +429,112 @@ impl Gradient {
/// The new stop's midpoint is inherited from the interval it splits (or `0.5` if inserting at the very start).
/// Returns the index where the new stop was inserted.
pub fn insert_stop(&mut self, position: f64) -> usize {
let color = self.evaluate(position);
let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len());
let midpoint = index.checked_sub(1).and_then(|i| self.midpoint.get(i).copied()).unwrap_or(0.5);
self.position.insert(index, position);
self.midpoint.insert(index, midpoint);
self.color.insert(index, color);
index
let color = self.evaluate(position, Default::default());
let index = (0..self.len()).position(|i| self.position(i) > position).unwrap_or(self.len());
let midpoint = if index > 0 { self.midpoint(index - 1) } else { 0.5 };
self.insert_stop_values(position, midpoint, color)
}
/// Insert a copy of the stop at `source_index` (same color and midpoint) at `position`, keeping the stops sorted by position.
/// Returns the index where the copy was inserted, or `None` if `source_index` is out of range.
pub fn duplicate_stop(&mut self, source_index: usize, position: f64) -> Option<usize> {
let color = *self.color.get(source_index)?;
let midpoint = *self.midpoint.get(source_index)?;
let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len());
self.position.insert(index, position);
self.midpoint.insert(index, midpoint);
self.color.insert(index, color);
Some(index)
let color = self.color(source_index)?;
let midpoint = self.midpoint(source_index);
Some(self.insert_stop_values(position, midpoint, color))
}
/// Splices a new stop into the sorted position, materializing explicit positions (an arbitrary insertion breaks even distribution)
/// while giving the new stop a midpoint cell only if the attribute already exists.
fn insert_stop_values(&mut self, position: f64, midpoint: f64, color: Color) -> usize {
self.materialize_default_positions();
let index = (0..self.len()).position(|i| self.position(i) > position).unwrap_or(self.len());
let mut item = Item::new_from_element(color).with_attribute(ATTR_POSITION, position);
if self.has_midpoint_attribute() {
item = item.with_attribute(ATTR_MIDPOINT, midpoint);
}
let mut list = self.reordered(0..index);
list.push(item);
for i in index..self.len() {
if let Some(existing) = self.0.clone_item(i) {
list.push(existing);
}
}
self.0 = list;
index
}
/// Reset the midpoint for the interval starting at `index` to its default `0.5`.
pub fn reset_midpoint(&mut self, index: usize) {
if let Some(midpoint) = self.midpoint.get_mut(index) {
*midpoint = 0.5;
if self.has_midpoint_attribute() && index < self.len() {
self.0.set_attribute(ATTR_MIDPOINT, index, 0.5);
}
}
/// Sort the stops in place by position; returns the new index of the stop that was at `previous_index` before sorting.
fn sort_returning_new_index(&mut self, previous_index: usize) -> usize {
let len = self.position.len();
let mut indices: Vec<usize> = (0..len).collect();
indices.sort_by(|&a, &b| self.position[a].total_cmp(&self.position[b]));
// An absent position attribute is an even distribution, which is already sorted
if !self.has_position_attribute() {
return previous_index;
}
let mut indices: Vec<usize> = (0..self.len()).collect();
indices.sort_by(|&a, &b| self.position(a).total_cmp(&self.position(b)));
let new_index = indices.iter().position(|&i| i == previous_index).unwrap_or(previous_index);
self.position = indices.iter().map(|&i| self.position[i]).collect();
self.midpoint = indices.iter().map(|&i| self.midpoint[i]).collect();
self.color = indices.iter().map(|&i| self.color[i]).collect();
self.0 = self.reordered(indices);
new_index
}
pub fn evaluate(&self, t: f64) -> Color {
if self.position.is_empty() {
return Color::BLACK;
/// 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.
fn normalized_stops(&self) -> Vec<GradientStop> {
let mut stops: Vec<GradientStop> = (0..self.len())
.filter_map(|index| {
let position = self.position(index).clamp(0., 1.);
if position.is_nan() {
return None;
}
let midpoint = self.midpoint(index);
let color = self.color(index)?;
Some(GradientStop { position, midpoint, color })
})
.collect();
stops.sort_by(|a, b| a.position.total_cmp(&b.position));
stops
}
/// Samples the gradient's color at `t`. Given a `t` outside the 0 to 1 range, the `spread_method` determines how the gradient extends.
pub fn evaluate(&self, t: f64, spread_method: GradientSpreadMethod) -> Color {
let t = match spread_method {
GradientSpreadMethod::Pad => t.clamp(0., 1.),
GradientSpreadMethod::Repeat => t.rem_euclid(1.),
GradientSpreadMethod::Reflect => {
let cycle = t.rem_euclid(2.);
if cycle > 1. { 2. - cycle } else { cycle }
}
};
let stops = self.normalized_stops();
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::BLACK };
if t <= first.position {
return first.color;
}
if t >= last.position {
return last.color;
}
if t <= self.position[0] {
return self.color[0];
}
let last = self.position.len() - 1;
if t >= self.position[last] {
return self.color[last];
}
for i in 0..self.position.len() - 1 {
let (t1, c1) = (self.position[i], self.color[i]);
let (t2, c2) = (self.position[i + 1], self.color[i + 1]);
if t >= t1 && t <= t2 {
let normalized_t = (t - t1) / (t2 - t1);
let adjusted_t = apply_midpoint(normalized_t, self.midpoint[i]);
return c1.lerp(&c2, adjusted_t as f32);
for pair in stops.windows(2) {
let (a, b) = (&pair[0], &pair[1]);
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);
return a.color.lerp(&b.color, adjusted_t as f32);
}
}
@@ -332,36 +542,43 @@ impl Gradient {
}
pub fn sort(&mut self) {
let mut indices: Vec<usize> = (0..self.position.len()).collect();
indices.sort_unstable_by(|&a, &b| self.position[a].total_cmp(&self.position[b]));
self.position = indices.iter().map(|&i| self.position[i]).collect();
self.midpoint = indices.iter().map(|&i| self.midpoint[i]).collect();
self.color = indices.iter().map(|&i| self.color[i]).collect();
self.sort_returning_new_index(0);
}
pub fn reversed(&self) -> Self {
let position: Vec<f64> = self.position.iter().rev().map(|&p| 1. - p).collect();
let count = self.len();
let mut list = self.reordered((0..count).rev());
let count = self.midpoint.len();
let midpoint = (0..count).map(|i| if i < count - 1 { 1. - self.midpoint[count - 2 - i] } else { 0.5 }).collect::<Vec<_>>();
// Row reversal already reversed the position cells' order, each also flips across the range
if self.has_position_attribute()
&& let Some(positions) = list.iter_attribute_values_mut::<f64>(ATTR_POSITION)
{
for position in positions {
*position = 1. - *position;
}
}
let color: Vec<Color> = self.color.iter().rev().cloned().collect();
// Midpoints belong to the interval to a stop's right, so they shift by one stop as well as flipping
if self.has_midpoint_attribute() {
let midpoints: Vec<f64> = (0..count).map(|i| if i + 1 < count { 1. - self.midpoint(count - 2 - i) } else { 0.5 }).collect();
for (index, midpoint) in midpoints.into_iter().enumerate() {
list.set_attribute(ATTR_MIDPOINT, index, midpoint);
}
}
Self { position, midpoint, color }
Self(list)
}
pub fn map_colors<F: Fn(&Color) -> Color>(&self, f: F) -> Self {
Self {
position: self.position.clone(),
midpoint: self.midpoint.clone(),
color: self.color.iter().map(f).collect(),
}
let mut mapped = self.clone();
mapped.0.iter_element_values_mut().for_each(|color| *color = f(color));
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 {
if self.position.len() <= 1 {
let hex = self.color.first().map(|c| SRGBA8::from(*c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_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
@@ -379,7 +596,7 @@ impl Gradient {
/// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves.
///
/// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding
/// midpoint for actual gradient stops, and `None` for interpolated samples added to approximate midpoint curves.
/// midpoint for actual gradient stops, and `None` for synthesized midpoint-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
@@ -419,23 +636,25 @@ impl Gradient {
}
}
if self.position.is_empty() {
let stops = self.normalized_stops();
let count = stops.len();
if count == 0 {
return vec![];
}
if self.position.len() == 1 {
return vec![(self.position[0], self.color[0], Some(self.midpoint[0]))];
if count == 1 {
return vec![(stops[0].position, stops[0].color, Some(sanitized_midpoint(stops[0].midpoint)))];
}
let mut result = Vec::new();
for i in 0..self.position.len() - 1 {
let pos_a = self.position[i];
let pos_b = self.position[i + 1];
let color_a = self.color[i];
let color_b = self.color[i + 1];
let midpoint = self.midpoint[i].clamp(0.01, 0.99);
let next_midpoint = self.midpoint[i + 1].clamp(0.01, 0.99);
for i in 0..count - 1 {
let pos_a = stops[i].position;
let pos_b = stops[i + 1].position;
let color_a = stops[i].color;
let color_b = stops[i + 1].color;
let midpoint = sanitized_midpoint(stops[i].midpoint);
let next_midpoint = sanitized_midpoint(stops[i + 1].midpoint);
// Add the start stop (subsequent segments share the previous end stop)
if i == 0 {
@@ -479,6 +698,7 @@ pub enum GradientSpreadMethod {
Pad,
Reflect,
Repeat,
// TODO: Add a "Clear" variant that returns transparent black outside the gradient's range
}
impl GradientSpreadMethod {
@@ -539,29 +759,6 @@ pub fn initial_gradient_transform_for_bounding_box(bounds: [DVec2; 2]) -> DAffin
}
}
// TODO: Eventually remove this migration document upgrade code
pub fn migrate_to_gradient<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Gradient, D::Error> {
use serde::Deserialize;
#[derive(serde::Deserialize)]
struct LegacyTable {
#[serde(alias = "instances", alias = "instance")]
element: Vec<Gradient>,
}
#[derive(serde::Deserialize)]
#[cfg_attr(feature = "serde", serde(untagged))]
enum GradientStopsFormat {
Stops(Gradient),
List(LegacyTable),
}
Ok(match GradientStopsFormat::deserialize(deserializer)? {
GradientStopsFormat::Stops(stops) => stops,
GradientStopsFormat::List(list) => list.element.into_iter().next().unwrap_or_default(),
})
}
impl core_types::bounds::BoundingBox for Gradient {
fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox {
core_types::bounds::RenderBoundingBox::Infinite
@@ -575,3 +772,148 @@ impl core_types::bounds::BoundingBox for Gradient {
core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_empty_and_black_to_white_is_the_artist_starting_gradient() {
assert!(Gradient::default().is_empty());
assert_eq!(Gradient::black_to_white().positions(), vec![0., 1.]);
assert_eq!(Gradient::default().evaluate(0.5, Default::default()), Color::BLACK);
}
#[test]
fn absent_attributes_default_to_even_positions_and_linear_midpoints() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]);
assert_eq!(gradient.positions(), vec![0., 0.5, 1.]);
assert_eq!(gradient.midpoints(), vec![0.5, 0.5, 0.5]);
}
#[test]
fn serde_round_trip_preserves_attribute_absence() {
let implicit = Gradient::from(vec![Color::BLACK, Color::WHITE]);
let json = serde_json::to_string(&implicit).unwrap();
assert!(!json.contains("position") && !json.contains("midpoint"), "absent attributes must not serialize: {json}");
assert_eq!(serde_json::from_str::<Gradient>(&json).unwrap(), implicit);
let mut explicit = implicit.clone();
explicit.set_positions(&[0.2, 0.9]);
explicit.set_midpoints(&[0.3, 0.5]);
let json = serde_json::to_string(&explicit).unwrap();
assert_eq!(serde_json::from_str::<Gradient>(&json).unwrap(), explicit);
}
#[test]
fn legacy_tuple_format_deserializes_with_defaults_elided() {
let color = serde_json::to_value(Color::WHITE).unwrap();
let struct_format = serde_json::json!({ "position": [0., 0.25], "midpoint": [0.5, 0.5], "color": [color, color] });
let gradient: Gradient = serde_json::from_value(struct_format).unwrap();
assert_eq!(gradient.positions(), vec![0., 0.25]);
assert!(gradient.has_midpoint_attribute(), "the struct form must parse faithfully");
let tuple_format = serde_json::json!([[0., color], [1., color]]);
let gradient: Gradient = serde_json::from_value(tuple_format).unwrap();
assert_eq!(gradient.positions(), vec![0., 1.]);
assert!(!gradient.has_position_attribute(), "even legacy tuple positions should elide");
}
#[test]
fn gradient_ui_write_back_elides_default_attributes() {
let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]);
gradient.set_midpoints(&[0.7, 0.5, 0.5]);
let round_tripped = Gradient::from(&GradientStops::<SRGBA8>::from(&gradient));
assert!(!round_tripped.has_position_attribute(), "materialized even positions should elide on write-back");
assert_eq!(round_tripped.midpoints(), vec![0.7, 0.5, 0.5]);
}
#[test]
fn nondefault_attributes_elide_default_values() {
let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]);
assert_eq!(gradient.nondefault_positions(), None);
assert_eq!(gradient.nondefault_midpoints(), None);
// Explicit attributes that merely restate the defaults still elide
gradient.set_positions(&[0., 0.5, 1.]);
gradient.set_midpoints(&[0.5, 0.5, 0.5]);
assert_eq!(gradient.nondefault_positions(), None);
assert_eq!(gradient.nondefault_midpoints(), None);
gradient.set_positions(&[0., 0.25, 1.]);
gradient.set_midpoints(&[0.5, 0.7, 0.5]);
assert_eq!(gradient.nondefault_positions(), Some(vec![0., 0.25, 1.]));
assert_eq!(gradient.nondefault_midpoints(), Some(vec![0.5, 0.7, 0.5]));
}
#[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
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(), vec![1.5, 0.4, -0.5]);
let sample_positions: Vec<f64> = gradient.interpolated_samples().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);
}
#[test]
fn infinite_positions_clamp_to_the_range_ends() {
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();
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);
}
#[test]
fn nan_positions_drop_their_stops_from_sampling() {
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();
assert_eq!(sample_positions, vec![0., 1.]);
assert_eq!(gradient.evaluate(0.5, Default::default()), Color::WHITE.lerp(&Color::RED, 0.5));
// A non-finite position is preserved as nondefault so write-back elision cannot resurrect the dropped stop
assert!(gradient.nondefault_positions().is_some());
// 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_eq!(gradient.evaluate(0.5, Default::default()), Color::BLACK);
}
#[test]
fn samples_start_at_the_first_stop_without_synthetic_lead_in() {
let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]);
gradient.set_positions(&[0.3, 1.]);
let samples = gradient.interpolated_samples();
assert_eq!(samples[0], (0.3, Color::WHITE, None), "renderers that need a flat lead-in before the first stop add it themselves");
}
#[test]
fn nan_midpoints_read_as_linear() {
let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
let linear_result = gradient.evaluate(0.25, Default::default());
gradient.set_midpoints(&[f64::NAN, f64::NAN]);
assert_eq!(gradient.evaluate(0.25, Default::default()), linear_result);
let no_nan_annotations = gradient
.interpolated_samples()
.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

@@ -28,7 +28,7 @@ pub enum FillChoice {
}
// TODO: Deprecate [`FillChoice`] and keep this, renamed, as the main widget-controlling type
/// JS-boundary version of [`FillChoice`] where the solid color is [`SRGBA8`] and the gradient is [`GradientUI`].
/// JS-boundary version of [`FillChoice`] where the solid color is [`SRGBA8`] and the gradient is its [`GradientStops`] exchange form.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Default, Debug, Clone, PartialEq, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -36,7 +36,7 @@ pub enum FillChoiceUI {
#[default]
None,
Solid(SRGBA8),
Gradient(GradientUI),
Gradient(GradientStops<SRGBA8>),
}
impl From<&FillChoice> for FillChoiceUI {
@@ -44,7 +44,7 @@ impl From<&FillChoice> for FillChoiceUI {
match value {
FillChoice::None => Self::None,
FillChoice::Solid(color) => Self::Solid(SRGBA8::from(*color)),
FillChoice::Gradient(stops) => Self::Gradient(GradientUI::from(stops)),
FillChoice::Gradient(stops) => Self::Gradient(stops.into()),
}
}
}
@@ -65,7 +65,7 @@ impl FillChoiceUI {
Some(*c)
}
pub fn as_gradient(&self) -> Option<&GradientUI> {
pub fn as_gradient(&self) -> Option<&GradientStops<SRGBA8>> {
let Self::Gradient(g) = self else { return None };
Some(g)
}

View File

@@ -6,7 +6,7 @@ use std::sync::atomic::AtomicU64;
use syn::punctuated::Punctuated;
use syn::spanned::Spanned;
use syn::token::Comma;
use syn::{Error, Ident, PatIdent, Token, WhereClause, WherePredicate, parse_quote};
use syn::{Error, Expr, ExprPath, Ident, PatIdent, Token, WhereClause, WherePredicate, parse_quote};
static NODE_ID: AtomicU64 = AtomicU64::new(0);
pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn) -> syn::Result<TokenStream2> {
@@ -191,6 +191,20 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
})
.collect();
let default_colors: Vec<_> = regular_fields
.iter()
.map(|field| match field.ty.regular() {
Some(RegularParsedField {
value_source: ParsedValueSource::Default(data),
..
}) => match color_constant_paths(data) {
Some(paths) => quote!(Some(&[#(#paths),*])),
None => quote!(None),
},
_ => quote!(None),
})
.collect();
let default_types: Vec<_> = regular_fields
.iter()
.enumerate()
@@ -858,6 +872,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
hidden: #input_hidden,
exposed: #exposed,
value_source: #value_sources,
default_colors: #default_colors,
default_type: #default_types,
number_soft_min: #number_soft_min_values,
number_soft_max: #number_soft_max_values,
@@ -881,6 +896,26 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
})
}
/// The `Color::*` constant paths making up a default expression, when it consists solely of them (the form used by color and gradient parameter defaults).
fn color_constant_paths(tokens: &TokenStream2) -> Option<Vec<ExprPath>> {
use syn::parse::Parser;
let expressions = Punctuated::<Expr, Token![,]>::parse_terminated.parse2(tokens.clone()).ok()?;
if expressions.is_empty() {
return None;
}
expressions
.into_iter()
.map(|expression| {
let Expr::Path(path) = expression else { return None };
let segments = &path.path.segments;
let is_color_constant = path.qself.is_none() && segments.len() == 2 && segments[0].ident == "Color" && segments.iter().all(|segment| segment.arguments.is_none());
is_color_constant.then_some(path)
})
.collect()
}
/// Generates the per-parameter symbol types used to reference this node's inputs.
fn generate_node_input_references(parsed: &ParsedNodeFn, field_idents: &[&PatIdent], core_types: &TokenStream2, identifier: &Ident, cfg: &TokenStream2) -> TokenStream2 {
let inputs_module_name = format_ident!("{}", parsed.struct_name.to_string().to_case(Case::Snake));

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;

View File

@@ -10,7 +10,7 @@ use rand::seq::SliceRandom;
use raster_types::{CPU, GPU, Raster};
use std::cmp::Ordering;
use vector_types::gradient::{GradientSpreadMethod, GradientType};
use vector_types::{Gradient, GradientStop, ReferencePoint};
use vector_types::{Gradient, ReferencePoint};
/// Returns the list with the item at the specified index removed.
/// If no value exists at that index, the list is returned unchanged.
@@ -1009,45 +1009,7 @@ pub async fn flatten_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations
/// 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) -> Item<Gradient> {
let colors = colors.into_flattened_list::<Color>();
let total_colors = colors.len();
if total_colors == 0 {
return Item::new_from_element(Gradient::new(vec![
GradientStop {
position: 0.,
midpoint: 0.5,
color: Color::BLACK,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: Color::BLACK,
},
]));
}
if let (1, Some(&single_color)) = (total_colors, colors.element(0)) {
return Item::new_from_element(Gradient::new(vec![
GradientStop {
position: 0.,
midpoint: 0.5,
color: single_color,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: single_color,
},
]));
}
let colors = colors.into_iter().enumerate().map(|(index, row)| GradientStop {
position: index as f64 / (total_colors - 1) as f64,
midpoint: 0.5,
color: row.into_element(),
});
Item::new_from_element(Gradient::new(colors))
Item::new_from_element(Gradient::from(colors.into_flattened_list::<Color>()))
}
#[cfg(test)]

View File

@@ -1373,7 +1373,7 @@ fn hex_to_color(_: impl Ctx, hex_code: Item<String>) -> Item<Color> {
/// 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: Item<Gradient>) -> Item<Gradient> {
fn gradient_value(_: impl Ctx, _primary: (), #[default(Color::BLACK, Color::WHITE)] gradient: Item<Gradient>) -> Item<Gradient> {
gradient
}
@@ -1393,11 +1393,35 @@ fn spread_method(_: impl Ctx, gradient: Item<Gradient>, spread_method: Item<vect
gradient
}
/// 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(_: impl Ctx, _primary: (), gradient: Item<Gradient>, position: Item<Fraction>) -> Item<Color> {
let position = position.element().clamp(0., 1.);
let color = gradient.element().evaluate(position);
fn gradient_positions(_: impl Ctx, gradient: Item<Gradient>, positions: List<f64>) -> Item<Gradient> {
let mut gradient = gradient;
let positions: Vec<f64> = positions.iter_element_values().copied().collect();
gradient.element_mut().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, gradient: Item<Gradient>, midpoints: List<f64>) -> Item<Gradient> {
let mut gradient = gradient;
let midpoints: Vec<f64> = midpoints.iter_element_values().copied().collect();
gradient.element_mut().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(_: impl Ctx, _primary: (), #[default(Color::BLACK, Color::WHITE)] gradient: Item<Gradient>, position: Item<Fraction>) -> Item<Color> {
let spread_method = gradient.attribute_cloned_or_default::<vector_types::GradientSpreadMethod>(core_types::ATTR_SPREAD_METHOD);
let color = gradient.element().evaluate(*position.element(), spread_method);
Item::new_from_element(color)
}

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);
}
}
}

View File

@@ -42,16 +42,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
}
}
}

View File

@@ -18,17 +18,18 @@ async fn gradient_map<T: Adjust<Color> + Send>(
Gradient,
)]
image: Item<T>,
gradient: Item<Gradient>,
#[default(Color::BLACK, Color::WHITE)] gradient: Item<Gradient>,
reverse: Item<bool>,
) -> Item<T> {
let mut image = image;
let spread_method = gradient.attribute_cloned_or_default::<vector_types::GradientSpreadMethod>(core_types::ATTR_SPREAD_METHOD);
let gradient = gradient.into_element();
let reverse = reverse.into_element();
image.element_mut().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

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 })

View File

@@ -118,6 +118,7 @@ async fn assign_colors<T>(
/// Whether to style the stroke.
stroke: Item<bool>,
/// The range of colors to select from.
#[default(Color::BLACK, Color::WHITE)]
#[widget(ParsedWidgetOverride::Custom = "assign_colors_gradient")]
gradient: Item<Gradient>,
/// Whether to reverse the gradient.
@@ -157,7 +158,7 @@ where
},
};
let color = gradient.evaluate(factor);
let color = gradient.evaluate(factor, Default::default());
let paint = List::new_from_element(color).into_graphic_list();
if fill {
@@ -189,7 +190,7 @@ async fn fill<V, F: IntoGraphicList + 'n + Send + 'static>(
)]
fill: F,
_backup_color: Item<Color>,
_backup_gradient: Item<Gradient>,
#[default(Color::BLACK, Color::WHITE)] _backup_gradient: Item<Gradient>,
_gradient_type: Item<GradientType>,
_spread_method: Item<GradientSpreadMethod>,
_has_transform: Item<bool>,
@@ -2454,14 +2455,12 @@ async fn morph<I: IntoGraphicList>(
.zip(color_list_b.element(0))
.map(|(color_a, color_b)| Graphic::from(color_a.lerp(color_b, time as f32))),
(Some(Graphic::Color(color_list_a)), Some(Graphic::Gradient(gradient_list_b))) => color_list_a.element(0).zip(gradient_list_b.element(0)).map(|(color_a, 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);
gradient_with_stops(gradient_list_b.clone(), stops)
}),
(Some(Graphic::Gradient(gradient_list_a)), Some(Graphic::Color(color_list_b))) => gradient_list_a.element(0).zip(color_list_b.element(0)).map(|(stops_a, 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);
gradient_with_stops(gradient_list_a.clone(), stops)
}),