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:
@@ -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)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user