mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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:
committed by
Dennis Kobert
parent
b78e4b107e
commit
86d4106592
@@ -2,7 +2,7 @@ use super::DocumentNode;
|
||||
use crate::application_io::PlatformEditorApi;
|
||||
use crate::application_io::resource::Resource;
|
||||
use crate::proto::Any as DAny;
|
||||
use brush_nodes::brush_stroke::{BrushStroke, BrushTrace};
|
||||
use brush_nodes::brush_stroke::BrushStroke;
|
||||
use core_types::color::SRGBA8;
|
||||
use core_types::context::Context;
|
||||
use core_types::gpoll::GPoll;
|
||||
@@ -29,7 +29,6 @@ use std::hash::Hash;
|
||||
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;
|
||||
@@ -63,25 +62,24 @@ macro_rules! tagged_value {
|
||||
/// Example: `TaggedValue::TypeDefault(descriptor!(String))` stores the type `String` but no specific string value.
|
||||
TypeDefault(TypeDescriptor),
|
||||
/// 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`.
|
||||
/// Stored compactly as a `Vec<f64>` of dash lengths, materializes as a `DashPattern` at runtime via `to_dynany`/`to_any`.
|
||||
DashPattern(Vec<f64>),
|
||||
/// Stored compactly as a `Vec<f64>` of corner values, materializes as an `Item<BoxCorners>` at runtime via `to_dynany`/`to_any`.
|
||||
/// Stored compactly as a `Vec<f64>` of corner values, materializes as a `BoxCorners` at runtime via `to_dynany`/`to_any`.
|
||||
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`, materializes as a single-row `List<Gradient>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
|
||||
/// Stored as the `{ color, position?, midpoint? }` stops struct, materializes as a single-row `List<Gradient>` at runtime via `to_dynany`/`to_any`. 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 `List<BrushStroke>` 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>),
|
||||
// =======================
|
||||
@@ -413,15 +411,10 @@ macro_rules! tagged_value {
|
||||
x if x == TypeId::of::<Vec<f64>>() => Ok(TaggedValue::F64Array(*downcast(input).unwrap())),
|
||||
x if x == TypeId::of::<List<f64>>() => Ok(TaggedValue::F64Array(downcast::<List<f64>>(input).unwrap().iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<DashPattern>() => Ok(TaggedValue::DashPattern(downcast::<DashPattern>(input).unwrap().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<Item<DashPattern>>() => Ok(TaggedValue::DashPattern(downcast::<Item<DashPattern>>(input).unwrap().into_element().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<BoxCorners>() => Ok(TaggedValue::BoxCorners(downcast::<BoxCorners>(input).unwrap().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<Item<BoxCorners>>() => Ok(TaggedValue::BoxCorners(downcast::<Item<BoxCorners>>(input).unwrap().into_element().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<Color>() => Ok(TaggedValue::Color(*downcast(input).unwrap())),
|
||||
x if x == TypeId::of::<Item<Color>>() => Ok(TaggedValue::Color(downcast::<Item<Color>>(input).unwrap().into_element())),
|
||||
x if x == TypeId::of::<Gradient>() => Ok(TaggedValue::Gradient(*downcast(input).unwrap())),
|
||||
x if x == TypeId::of::<Item<Gradient>>() => Ok(TaggedValue::Gradient(downcast::<Item<Gradient>>(input).unwrap().into_element())),
|
||||
x if x == TypeId::of::<Vec<BrushStroke>>() => Ok(TaggedValue::BrushStrokes(*downcast(input).unwrap())),
|
||||
x if x == TypeId::of::<Item<BrushTrace>>() => Ok(TaggedValue::BrushStrokes(downcast::<Item<BrushTrace>>(input).unwrap().into_element().0.iter_element_values().cloned().collect())),
|
||||
// =======================
|
||||
// AUTO-GENERATED VARIANTS
|
||||
// =======================
|
||||
@@ -448,15 +441,10 @@ macro_rules! tagged_value {
|
||||
x if x == TypeId::of::<Vec<f64>>() => Ok(TaggedValue::F64Array(input.downcast_ref::<Vec<f64>>().unwrap().clone())),
|
||||
x if x == TypeId::of::<List<f64>>() => Ok(TaggedValue::F64Array(input.downcast_ref::<List<f64>>().unwrap().iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<DashPattern>() => Ok(TaggedValue::DashPattern(input.downcast_ref::<DashPattern>().unwrap().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<Item<DashPattern>>() => Ok(TaggedValue::DashPattern(input.downcast_ref::<Item<DashPattern>>().unwrap().element().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<BoxCorners>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::<BoxCorners>().unwrap().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<Item<BoxCorners>>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::<Item<BoxCorners>>().unwrap().element().0.iter_element_values().copied().collect())),
|
||||
x if x == TypeId::of::<Color>() => Ok(TaggedValue::Color(*input.downcast_ref::<Color>().unwrap())),
|
||||
x if x == TypeId::of::<Item<Color>>() => Ok(TaggedValue::Color(*input.downcast_ref::<Item<Color>>().unwrap().element())),
|
||||
x if x == TypeId::of::<Gradient>() => Ok(TaggedValue::Gradient(input.downcast_ref::<Gradient>().unwrap().clone())),
|
||||
x if x == TypeId::of::<Item<Gradient>>() => Ok(TaggedValue::Gradient(input.downcast_ref::<Item<Gradient>>().unwrap().element().clone())),
|
||||
x if x == TypeId::of::<Vec<BrushStroke>>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::<Vec<BrushStroke>>().unwrap().clone())),
|
||||
x if x == TypeId::of::<Item<BrushTrace>>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::<Item<BrushTrace>>().unwrap().element().0.iter_element_values().cloned().collect())),
|
||||
// =======================
|
||||
// AUTO-GENERATED VARIANTS
|
||||
// =======================
|
||||
@@ -698,29 +686,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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,7 +795,7 @@ impl TaggedValue {
|
||||
/// - `FillChoice` → `TaggedValue::Color` (solid), `TaggedValue::Gradient` (gradient), or `TaggedValue::no_paint()` (none)
|
||||
///
|
||||
/// 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;
|
||||
@@ -882,11 +854,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)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -699,6 +699,12 @@ attribute! {
|
||||
/// glyph origin so it survives 'Index Elements' filtering. The Text tool reads this to
|
||||
/// position its drag cage.
|
||||
pub EditorTextFrame("editor:text_frame"): DAffine2;
|
||||
/// Gradient stop's 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 Position("position"): f64;
|
||||
/// Gradient stop's midpoint, 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 Midpoint("midpoint"): f64 = 0.5;
|
||||
/// Byte offset where a regex match begins ('Regex Find All' and 'Regex Capture' text nodes).
|
||||
pub Start("start"): u64;
|
||||
/// Byte offset where a regex match ends ('Regex Find All' and 'Regex Capture' text nodes).
|
||||
|
||||
@@ -22,6 +22,8 @@ pub const ATTR_OPACITY_FILL: &str = crate::attribute::OpacityFill::NAME;
|
||||
pub const ATTR_CLIPPING_MASK: &str = crate::attribute::ClippingMask::NAME;
|
||||
pub const ATTR_EDITOR_LAYER_PATH: &str = crate::attribute::EditorLayerPath::NAME;
|
||||
pub const ATTR_EDITOR_TEXT_FRAME: &str = crate::attribute::EditorTextFrame::NAME;
|
||||
pub const ATTR_POSITION: &str = crate::attribute::Position::NAME;
|
||||
pub const ATTR_MIDPOINT: &str = crate::attribute::Midpoint::NAME;
|
||||
pub const ATTR_START: &str = crate::attribute::Start::NAME;
|
||||
pub const ATTR_END: &str = crate::attribute::End::NAME;
|
||||
pub const ATTR_NAME: &str = crate::attribute::Name::NAME;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::concrete;
|
||||
use crate::context::{Context, ContextImpl};
|
||||
use crate::node::Node;
|
||||
use crate::{ContextFeature, ProtoNodeIdentifier, Type, WasmNotSend, WasmNotSync};
|
||||
use crate::{Color, ContextFeature, ProtoNodeIdentifier, Type, WasmNotSend, WasmNotSync};
|
||||
use dyn_any::DynAny;
|
||||
use graphene_hash::CacheHash;
|
||||
pub use no_std_types::registry::types;
|
||||
@@ -35,6 +35,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>,
|
||||
|
||||
@@ -17,7 +17,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;
|
||||
@@ -116,7 +116,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;
|
||||
|
||||
@@ -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.,
|
||||
|
||||
@@ -127,6 +127,11 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = 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()
|
||||
|
||||
@@ -400,6 +400,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<S: LaneSource<Element = Gradient>>(gradient_list: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
|
||||
let stops = gradient_list.element(0)?;
|
||||
|
||||
@@ -407,13 +432,7 @@ fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list
|
||||
let gradient_transform: DAffine2 = gradient_list.attr::<Transform>(0);
|
||||
let spread_method: GradientSpreadMethod = gradient_list.attr::<SpreadMethod>(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));
|
||||
@@ -2403,13 +2422,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 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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::atomic::AtomicU64;
|
||||
use syn::punctuated::Punctuated;
|
||||
use syn::visit::Visit;
|
||||
use syn::visit_mut::VisitMut;
|
||||
use syn::{GenericArgument, GenericParam, Ident, Lifetime, PatIdent, PathArguments, Type, TypeParam, TypeParamBound};
|
||||
use syn::{Expr, ExprPath, GenericArgument, GenericParam, Ident, Lifetime, PatIdent, PathArguments, Token, Type, TypeParam, TypeParamBound};
|
||||
|
||||
pub(crate) mod classify;
|
||||
mod entries;
|
||||
@@ -348,6 +348,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()
|
||||
.map(|field| match &field.ty {
|
||||
@@ -682,6 +696,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,
|
||||
@@ -2959,3 +2974,22 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -267,6 +267,16 @@ pub enum ParsedFieldType {
|
||||
Node(NodeParsedField),
|
||||
}
|
||||
|
||||
impl ParsedFieldType {
|
||||
/// The shared value-field data, present for every value field but not a lazy `Node`.
|
||||
pub fn regular(&self) -> Option<&RegularParsedField> {
|
||||
match self {
|
||||
ParsedFieldType::Regular(field) => Some(field),
|
||||
ParsedFieldType::Node(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single numeric endpoint within a `#[soft(..)]` or `#[hard(..)]` bounds range.
|
||||
/// Accepts both integer literals (e.g. `1`, `-1`) and float literals (e.g. `1.`, `-500.`).
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
|
||||
use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector};
|
||||
use raster_types::{CPU, GPU, Raster};
|
||||
use vector_types::gradient::{GradientSpreadMethod, GradientType as GradientTypeValue};
|
||||
use vector_types::{Gradient, GradientStop, ReferencePoint};
|
||||
use vector_types::{Gradient, ReferencePoint};
|
||||
|
||||
fn arena_exhausted() -> Interrupt {
|
||||
GraphError {
|
||||
@@ -555,21 +555,10 @@ pub fn flatten_gradient<'e>(
|
||||
flatten_leaf_lane(content, ctx.index() as usize)
|
||||
}
|
||||
|
||||
/// A gradient with `colors` as evenly spaced stops from 0 to 1; none makes a
|
||||
/// black gradient and one repeats at both ends.
|
||||
fn evenly_spaced_gradient(colors: &[Color]) -> Gradient {
|
||||
let stop = |position: f64, color: Color| GradientStop { position, midpoint: 0.5, color };
|
||||
match colors {
|
||||
[] => Gradient::new(vec![stop(0., Color::BLACK), stop(1., Color::BLACK)]),
|
||||
[color] => Gradient::new(vec![stop(0., *color), stop(1., *color)]),
|
||||
colors => Gradient::new(colors.iter().enumerate().map(|(index, color)| stop(index as f64 / (colors.len() - 1) as f64, *color))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"))]
|
||||
pub fn colors_to_gradient(_: impl Ctx, colors: IList<Color>) -> Gradient {
|
||||
evenly_spaced_gradient(&colors.iter().collect::<Vec<_>>())
|
||||
Gradient::from(colors.iter().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// The gradient over a graphic level's color leaves, as [`colors_to_gradient`].
|
||||
@@ -583,7 +572,7 @@ pub fn colors_to_gradient_graphic(_: impl Ctx, colors: IList<Graphic<'static>>)
|
||||
RowStep::Continue
|
||||
});
|
||||
}
|
||||
evenly_spaced_gradient(&leaves)
|
||||
Gradient::from(leaves)
|
||||
}
|
||||
|
||||
pub use _colors_to_gradient_graphic_mod::colors_to_gradient_graphic_entries;
|
||||
|
||||
@@ -796,11 +796,12 @@ mod tests {
|
||||
assert_eq!(three.iter().map(|stop| stop.position).collect::<Vec<_>>(), vec![0., 0.5, 1.]);
|
||||
assert_eq!(three.iter().map(|stop| stop.color).collect::<Vec<_>>(), vec![Color::BLACK, Color::WHITE, Color::BLACK]);
|
||||
|
||||
// A lone color is a one-stop gradient and no colors a stopless one; neither is padded
|
||||
let single = stops_of(vec![Color::WHITE]);
|
||||
assert_eq!(single.iter().map(|stop| (stop.position, stop.color)).collect::<Vec<_>>(), vec![(0., Color::WHITE), (1., Color::WHITE)]);
|
||||
assert_eq!(single.iter().map(|stop| (stop.position, stop.color)).collect::<Vec<_>>(), vec![(0., Color::WHITE)]);
|
||||
|
||||
let empty = stops_of(Vec::new());
|
||||
assert_eq!(empty.iter().map(|stop| (stop.position, stop.color)).collect::<Vec<_>>(), vec![(0., Color::BLACK), (1., Color::BLACK)]);
|
||||
assert!(empty.iter().next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1198,7 +1198,7 @@ fn hex_to_color(ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, hex_code: Str
|
||||
|
||||
/// Constructs a gradient value which may be set to any sequence of color stops to represent the transition between colors.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn gradient_value(_: impl Ctx, _primary: (), gradient: Gradient) -> Gradient {
|
||||
fn gradient_value(_: impl Ctx, _primary: (), #[default(Color::BLACK, Color::WHITE)] gradient: Gradient) -> Gradient {
|
||||
gradient
|
||||
}
|
||||
|
||||
@@ -1214,16 +1214,43 @@ fn spread_method(_: impl Ctx, gradient: Gradient, spread_method: vector_types::G
|
||||
(gradient, Attr(spread_method))
|
||||
}
|
||||
|
||||
/// Gets the color at the specified position along the gradient, given a position from 0 (left) to 1 (right).
|
||||
/// Sets the position of each of a gradient's stops, a factor from 0 to 1 along the gradient.
|
||||
///
|
||||
/// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each stop to its default evenly spaced position.
|
||||
#[node_macro::node(category("Color"))]
|
||||
fn sample_gradient(ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, _primary: (), gradient: IList<Gradient>, position: Fraction) -> Result<IList<Color>, Interrupt> {
|
||||
fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: IList<f64>) -> Gradient {
|
||||
let positions: Vec<f64> = positions.iter().collect();
|
||||
gradient.set_positions(&positions);
|
||||
gradient
|
||||
}
|
||||
|
||||
/// Sets the interpolation midpoint for each interval between gradient stops, a factor from 0 to 1 where the 0.5 default means linear interpolation and another value skews the transition speed toward one stop or the other.
|
||||
///
|
||||
/// The final stop belongs to no interval so its midpoint is ignored.
|
||||
///
|
||||
/// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each midpoint to its default of 0.5.
|
||||
#[node_macro::node(category("Color"))]
|
||||
fn gradient_midpoints(_: impl Ctx, mut gradient: Gradient, midpoints: IList<f64>) -> Gradient {
|
||||
let midpoints: Vec<f64> = midpoints.iter().collect();
|
||||
gradient.set_midpoints(&midpoints);
|
||||
gradient
|
||||
}
|
||||
|
||||
/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `spread_method` attribute: Pad (default), Reflect, or Repeat.
|
||||
#[node_macro::node(category("Color"))]
|
||||
fn sample_gradient(
|
||||
ctx: impl Ctx + ExtractIndex + InjectIndex + Copy,
|
||||
_primary: (),
|
||||
#[default(Color::BLACK, Color::WHITE)] gradient: IList<Gradient>,
|
||||
position: Fraction,
|
||||
) -> Result<IList<Color>, Interrupt> {
|
||||
// An unwired gradient serves an empty level: no color
|
||||
if gradient.is_empty() || ctx.index() != 0 {
|
||||
return Err(GraphError::past_end().into());
|
||||
}
|
||||
|
||||
let position = position.clamp(0., 1.);
|
||||
Ok(gradient.element_ref(0).evaluate(position))
|
||||
let spread_method = gradient.lane(0).attr::<SpreadMethodAttr>();
|
||||
Ok(gradient.element_ref(0).evaluate(position, spread_method))
|
||||
}
|
||||
|
||||
/// Constructs a footprint value which may be set to any transformation of a unit square describing a render area, and a render resolution at least 1x1 integer pixels.
|
||||
|
||||
@@ -24,9 +24,7 @@ mod adjust_std {
|
||||
}
|
||||
impl Adjust<Color> for Gradient {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
|
||||
for color in self.color.iter_mut() {
|
||||
*color = map_fn(color);
|
||||
}
|
||||
*self = self.map_colors(map_fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,16 +38,19 @@ mod blend_std {
|
||||
|
||||
impl Blend<Color> for Gradient {
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self {
|
||||
let mut combined_stops = self.position.iter().chain(under.position.iter()).copied().collect::<Vec<_>>();
|
||||
combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
let mut combined_stops = self.positions().into_iter().chain(under.positions()).collect::<Vec<_>>();
|
||||
combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
|
||||
combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
let stops = combined_stops.into_iter().map(|position| {
|
||||
let over_color = self.evaluate(position);
|
||||
let under_color = under.evaluate(position);
|
||||
let over_color = self.evaluate(position, Default::default());
|
||||
let under_color = under.evaluate(position, Default::default());
|
||||
let color = blend_fn(over_color, under_color);
|
||||
GradientStop { position, midpoint: 0.5, color }
|
||||
});
|
||||
Gradient::new(stops)
|
||||
|
||||
let mut gradient = Gradient::new(stops);
|
||||
gradient.elide_default_attributes();
|
||||
gradient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,19 @@ fn gradient_map<T: Adjust<Color> + Clone + Send + Sync + core_types::CacheHash +
|
||||
Gradient,
|
||||
)]
|
||||
mut image: T,
|
||||
gradient: IList<Gradient>,
|
||||
#[default(Color::BLACK, Color::WHITE)] gradient: IList<Gradient>,
|
||||
reverse: bool,
|
||||
) -> T {
|
||||
if gradient.is_empty() {
|
||||
return image;
|
||||
}
|
||||
let spread_method = gradient.lane(0).attr::<vector_types::markers::SpreadMethod>();
|
||||
let gradient = gradient.element_ref(0);
|
||||
|
||||
image.adjust(|color| {
|
||||
let intensity = color.luminance_rec_709();
|
||||
let intensity = if reverse { 1. - intensity } else { intensity };
|
||||
gradient.evaluate(intensity as f64)
|
||||
gradient.evaluate(intensity as f64, spread_method)
|
||||
});
|
||||
|
||||
image
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -61,7 +61,7 @@ fn assign_color_at(gradient: &Gradient, position: usize, length: usize, randomiz
|
||||
_ => position as f64 % repeat_every as f64 / (repeat_every - 1) as f64,
|
||||
},
|
||||
};
|
||||
gradient.evaluate(factor)
|
||||
gradient.evaluate(factor, Default::default())
|
||||
}
|
||||
|
||||
/// Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient.
|
||||
@@ -77,6 +77,7 @@ fn assign_colors<'e>(
|
||||
/// Whether to style the stroke.
|
||||
stroke: bool,
|
||||
/// The range of colors to select from.
|
||||
#[default(Color::BLACK, Color::WHITE)]
|
||||
#[widget(ParsedWidgetOverride::Custom = "assign_colors_gradient")]
|
||||
gradient: IList<Gradient>,
|
||||
/// Whether to reverse the gradient.
|
||||
@@ -323,7 +324,7 @@ fn fill<'e>(
|
||||
#[default(Color::BLACK)]
|
||||
fill: IList<Graphic<'static>>,
|
||||
_backup_color: IList<Color>,
|
||||
_backup_gradient: IList<Gradient>,
|
||||
#[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList<Gradient>,
|
||||
_gradient_type: GradientType,
|
||||
_spread_method: GradientSpreadMethod,
|
||||
_has_transform: bool,
|
||||
@@ -344,7 +345,7 @@ fn fill_graphic_leveled<'e>(
|
||||
(element, _content_fill): (Graphic<'static>, Attr<Fill>),
|
||||
#[default(Color::BLACK)] fill: IList<Graphic<'static>>,
|
||||
_backup_color: IList<Color>,
|
||||
_backup_gradient: IList<Gradient>,
|
||||
#[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList<Gradient>,
|
||||
_gradient_type: GradientType,
|
||||
_spread_method: GradientSpreadMethod,
|
||||
_has_transform: bool,
|
||||
@@ -2950,14 +2951,12 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
|
||||
match (a.element(0), b.element(0)) {
|
||||
(Some(Graphic::Color(color_a)), Some(Graphic::Color(color_b))) => Some(List::new_from_element(Graphic::from(color_a.lerp(color_b, time as f32)))),
|
||||
(Some(Graphic::Color(color_a)), Some(Graphic::Gradient(stops_b))) => {
|
||||
let mut solid_to_gradient = stops_b.clone();
|
||||
solid_to_gradient.color.iter_mut().for_each(|color| *color = *color_a);
|
||||
let solid_to_gradient = stops_b.map_colors(|_| *color_a);
|
||||
let stops = solid_to_gradient.lerp(stops_b, time);
|
||||
Some(gradient_paint(b, stops, None))
|
||||
}
|
||||
(Some(Graphic::Gradient(stops_a)), Some(Graphic::Color(color_b))) => {
|
||||
let mut gradient_to_solid = stops_a.clone();
|
||||
gradient_to_solid.color.iter_mut().for_each(|color| *color = *color_b);
|
||||
let gradient_to_solid = stops_a.map_colors(|_| *color_b);
|
||||
let stops = stops_a.lerp(&gradient_to_solid, time);
|
||||
Some(gradient_paint(a, stops, None))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user