Rework Gradient into a newtype of List<Color> with optional position and midpoint attributes (#4397)

* Rework Gradient into a newtype of List<Color> with optional position and midpoint attributes

* Fix Vello stopless-gradient fallback coverage, empty legacy gradient tables, the node docs gradient swatch, NaN position elision, and wired setter input overwrites
This commit is contained in:
Keavon Chambers
2026-09-14 12:58:16 +02:00
committed by Dennis Kobert
parent b78e4b107e
commit 86d4106592
36 changed files with 1112 additions and 464 deletions
Generated
+1
View File
@@ -6614,6 +6614,7 @@ dependencies = [
"polycool", "polycool",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"serde", "serde",
"serde_json",
"tinyvec", "tinyvec",
"tsify", "tsify",
"wasm-bindgen", "wasm-bindgen",
@@ -5,7 +5,7 @@ use crate::messages::prelude::*;
use graphene_std::Color; use graphene_std::Color;
use graphene_std::color::SRGBA8; use graphene_std::color::SRGBA8;
use graphene_std::core_types::misc::parse_css_color; use graphene_std::core_types::misc::parse_css_color;
use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientUI}; use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientStops};
/// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops). /// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops).
const MIN_MIDPOINT: f64 = 0.01; const MIN_MIDPOINT: f64 = 0.01;
@@ -82,7 +82,7 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
FillChoice::Gradient(stops) => { FillChoice::Gradient(stops) => {
self.active_marker_index = Some(0); self.active_marker_index = Some(0);
self.active_marker_is_midpoint = false; self.active_marker_is_midpoint = false;
let first_color = stops.color.first().copied().unwrap_or(Color::BLACK); let first_color = stops.color(0).unwrap_or(Color::BLACK);
self.gradient = Some(stops); self.gradient = Some(stops);
self.adopt_color(first_color); self.adopt_color(first_color);
} }
@@ -266,9 +266,9 @@ impl ColorPickerMessageHandler {
if let Some(gradient) = &mut self.gradient if let Some(gradient) = &mut self.gradient
&& let Some(active_index) = self.active_marker_index && let Some(active_index) = self.active_marker_index
&& let Some(stop_color) = gradient.color.get_mut(active_index as usize) && (active_index as usize) < gradient.len()
{ {
*stop_color = color; gradient.set_color(active_index as usize, color);
let stops = gradient.clone(); let stops = gradient.clone();
let fill_choice = FillChoice::Gradient(stops); let fill_choice = FillChoice::Gradient(stops);
responses.add(FrontendMessage::ColorPickerColorChanged { responses.add(FrontendMessage::ColorPickerColorChanged {
@@ -305,7 +305,7 @@ impl ColorPickerMessageHandler {
self.active_marker_is_midpoint = active_marker_is_midpoint; self.active_marker_is_midpoint = active_marker_is_midpoint;
if let Some(index) = active_marker_index if let Some(index) = active_marker_index
&& let Some(gradient) = &self.gradient && let Some(gradient) = &self.gradient
&& let Some(color) = gradient.color.get(index as usize).copied() && let Some(color) = gradient.color(index as usize)
{ {
self.adopt_color(color); self.adopt_color(color);
self.snapshot_old(); self.snapshot_old();
@@ -324,17 +324,16 @@ impl ColorPickerMessageHandler {
} }
} }
SpectrumInputUpdate::MoveMidpoint { index, position } => { SpectrumInputUpdate::MoveMidpoint { index, position } => {
if let Some(midpoint) = gradient.midpoint.get_mut(index as usize) { if (index as usize) >= gradient.len() {
*midpoint = position.clamp(MIN_MIDPOINT, MAX_MIDPOINT);
} else {
return; return;
} }
gradient.set_midpoint(index as usize, position.clamp(MIN_MIDPOINT, MAX_MIDPOINT));
} }
SpectrumInputUpdate::InsertMarker { position } => { SpectrumInputUpdate::InsertMarker { position } => {
let new_index = gradient.insert_stop(position); let new_index = gradient.insert_stop(position);
self.active_marker_index = Some(new_index as u32); self.active_marker_index = Some(new_index as u32);
self.active_marker_is_midpoint = false; self.active_marker_is_midpoint = false;
if let Some(color) = gradient.color.get(new_index).copied() { if let Some(color) = gradient.color(new_index) {
self.adopt_color(color); self.adopt_color(color);
self.snapshot_old(); self.snapshot_old();
} }
@@ -349,7 +348,7 @@ impl ColorPickerMessageHandler {
} }
SpectrumInputUpdate::RemoveDuplicate { index } => { SpectrumInputUpdate::RemoveDuplicate { index } => {
let anchor = index as usize; let anchor = index as usize;
if anchor >= gradient.position.len() || gradient.position.len() <= 2 { if anchor >= gradient.len() || gradient.len() <= 2 {
return; return;
} }
// Never remove the active (dragged) stop itself, this should only ever target the frozen copy. // Never remove the active (dragged) stop itself, this should only ever target the frozen copy.
@@ -366,14 +365,14 @@ impl ColorPickerMessageHandler {
} }
SpectrumInputUpdate::DeleteMarker { index } => { SpectrumInputUpdate::DeleteMarker { index } => {
// Enforce minimum stop count. The gradient editor needs at least 2 stops to remain meaningful. // Enforce minimum stop count. The gradient editor needs at least 2 stops to remain meaningful.
if gradient.position.len() <= 2 || (index as usize) >= gradient.position.len() { if gradient.len() <= 2 || (index as usize) >= gradient.len() {
return; return;
} }
gradient.remove(index as usize); gradient.remove(index as usize);
let new_active = (index as usize).min(gradient.position.len() - 1); let new_active = (index as usize).min(gradient.len() - 1);
self.active_marker_index = Some(new_active as u32); self.active_marker_index = Some(new_active as u32);
self.active_marker_is_midpoint = false; self.active_marker_is_midpoint = false;
if let Some(color) = gradient.color.get(new_active).copied() { if let Some(color) = gradient.color(new_active) {
self.adopt_color(color); self.adopt_color(color);
self.snapshot_old(); self.snapshot_old();
} }
@@ -383,13 +382,13 @@ impl ColorPickerMessageHandler {
} }
SpectrumInputUpdate::ResetMarker { index } => { SpectrumInputUpdate::ResetMarker { index } => {
let i = index as usize; let i = index as usize;
let count = gradient.position.len(); let count = gradient.len();
if i >= count { if i >= count {
return; return;
} }
// Each stop's "natural" position is its evenly-spaced fraction along 0..1, e.g., for 5 stops: 0, 0.25, 0.5, 0.75, 1. Falls back to the midpoint between neighbors when the natural position would push the stop past another. // Each stop's "natural" position is its evenly-spaced fraction along 0..1, e.g., for 5 stops: 0, 0.25, 0.5, 0.75, 1. Falls back to the midpoint between neighbors when the natural position would push the stop past another.
let left = if i == 0 { 0. } else { gradient.position[i - 1] }; let left = if i == 0 { 0. } else { gradient.position(i - 1) };
let right = gradient.position.get(i + 1).copied().unwrap_or(1.); let right = if i + 1 < count { gradient.position(i + 1) } else { 1. };
let natural = if count <= 1 { 0. } else { i as f64 / (count - 1) as f64 }; let natural = if count <= 1 { 0. } else { i as f64 / (count - 1) as f64 };
let new_position = if (left..=right).contains(&natural) { natural } else { (left + right) / 2. }; let new_position = if (left..=right).contains(&natural) { natural } else { (left + right) / 2. };
let new_index = gradient.move_stop(i, new_position); let new_index = gradient.move_stop(i, new_position);
@@ -430,7 +429,7 @@ impl ColorPickerMessageHandler {
// For gradient editing, the markers' handle colors mirror their gradient stop colors // For gradient editing, the markers' handle colors mirror their gradient stop colors
let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect(); let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect();
let mut row_widgets = vec![ let mut row_widgets = vec![
SpectrumInput::new(GradientUI::from(gradient)) SpectrumInput::new(GradientStops::from(gradient))
.markers(markers) .markers(markers)
.active_marker_index(self.active_marker_index) .active_marker_index(self.active_marker_index)
.active_marker_is_midpoint(self.active_marker_is_midpoint) .active_marker_is_midpoint(self.active_marker_is_midpoint)
@@ -445,10 +444,10 @@ impl ColorPickerMessageHandler {
if let Some(active) = self.active_marker_index { if let Some(active) = self.active_marker_index {
let active_index = active as usize; let active_index = active as usize;
let position_value = if self.active_marker_is_midpoint { let position_value = match (self.active_marker_is_midpoint, active_index < gradient.len()) {
gradient.midpoint.get(active_index).copied().unwrap_or(0.) (_, false) => 0.,
} else { (true, true) => gradient.midpoint(active_index),
gradient.position.get(active_index).copied().unwrap_or(0.) (false, true) => gradient.position(active_index),
}; };
let is_midpoint = self.active_marker_is_midpoint; let is_midpoint = self.active_marker_is_midpoint;
let captured_index = active; let captured_index = active;
@@ -6,7 +6,7 @@ use derivative::*;
use graphene_std::Color; use graphene_std::Color;
use graphene_std::color::SRGBA8; use graphene_std::color::SRGBA8;
use graphene_std::transform::ReferencePoint; use graphene_std::transform::ReferencePoint;
use graphene_std::vector::style::{FillChoiceUI, GradientUI}; use graphene_std::vector::style::{FillChoiceUI, GradientStops};
use graphite_proc_macros::WidgetBuilder; use graphite_proc_macros::WidgetBuilder;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -531,7 +531,7 @@ pub struct SpectrumInput {
// Content // Content
/// The colored gradient drawn behind the markers (display-only, caller-owned). /// The colored gradient drawn behind the markers (display-only, caller-owned).
#[widget_builder(constructor)] #[widget_builder(constructor)]
pub track: GradientUI, pub track: GradientStops<SRGBA8>,
/// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time. /// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time.
#[serde(rename = "trackCSS")] #[serde(rename = "trackCSS")]
#[widget_builder(skip)] #[widget_builder(skip)]
@@ -35,6 +35,14 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
stops: Gradient, stops: Gradient,
}, },
GradientPositionsSet {
layer: LayerNodeIdentifier,
positions: Vec<f64>,
},
GradientMidpointsSet {
layer: LayerNodeIdentifier,
midpoints: Vec<f64>,
},
GradientTransformSet { GradientTransformSet {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
transform: DAffine2, transform: DAffine2,
@@ -61,6 +61,16 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
modify_inputs.gradient_stops_set(stops); modify_inputs.gradient_stops_set(stops);
} }
} }
GraphOperationMessage::GradientPositionsSet { layer, positions } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.gradient_positions_set(positions);
}
}
GraphOperationMessage::GradientMidpointsSet { layer, midpoints } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.gradient_midpoints_set(midpoints);
}
}
GraphOperationMessage::GradientTransformSet { layer, transform } => { GraphOperationMessage::GradientTransformSet { layer, transform } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.gradient_transform_set(transform); modify_inputs.gradient_transform_set(transform);
@@ -552,6 +552,57 @@ impl<'a> ModifyInputsContext<'a> {
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Gradient(stops), false), false); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Gradient(stops), false), false);
} }
/// Update the last 'Gradient Positions' node in the chain when one exists, so on-canvas stop drags stay live even
/// though that node would otherwise override the stops value's own placement. Never inserts one: the stops value
/// carries placement itself, and these setter nodes are user-authored procedural overrides. A wired input is
/// procedural authorship too, so it is likewise left untouched.
pub fn gradient_positions_set(&mut self, positions: Vec<f64>) {
let Some(output_layer) = self.get_output_layer() else { return };
let target_input = gradient_chain_target_input(output_layer, self.network_interface);
let identifier = graphene_std::math_nodes::gradient_positions::IDENTIFIER;
let Some(node_id) = self.existing_proto_node_id_at(&target_input, identifier, false) else {
return;
};
let current_input = self
.network_interface
.document_network()
.nodes
.get(&node_id)
.and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput));
if !current_input.is_some_and(|input| input.as_value().is_some()) {
return;
}
let input_connector = InputConnector::node(node_id, graphene_std::math_nodes::gradient_positions::PositionsInput);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64Array(positions), false), false);
}
/// The 'Gradient Midpoints' counterpart of [`Self::gradient_positions_set`], likewise update-only.
pub fn gradient_midpoints_set(&mut self, midpoints: Vec<f64>) {
let Some(output_layer) = self.get_output_layer() else { return };
let target_input = gradient_chain_target_input(output_layer, self.network_interface);
let identifier = graphene_std::math_nodes::gradient_midpoints::IDENTIFIER;
let Some(node_id) = self.existing_proto_node_id_at(&target_input, identifier, false) else {
return;
};
let current_input = self
.network_interface
.document_network()
.nodes
.get(&node_id)
.and_then(|node| node.input(graphene_std::math_nodes::gradient_midpoints::MidpointsInput));
if !current_input.is_some_and(|input| input.as_value().is_some()) {
return;
}
let input_connector = InputConnector::node(node_id, graphene_std::math_nodes::gradient_midpoints::MidpointsInput);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64Array(midpoints), false), false);
}
/// Update the transform to map the unit gradient ((0,0), (1, 0)) to the geometry's local space. /// Update the transform to map the unit gradient ((0,0), (1, 0)) to the geometry's local space.
/// With multiple `Transform` nodes the last one (closest to the layer) is modified so the chain still composes to the target. /// With multiple `Transform` nodes the last one (closest to the layer) is modified so the chain still composes to the target.
/// With none, one is inserted unless the target is the identity. /// With none, one is inserted unless the target is the identity.
@@ -33,7 +33,7 @@ use graphene_std::vector::misc::BooleanOperation;
use graphene_std::vector::misc::{ use graphene_std::vector::misc::{
ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType,
}; };
use graphene_std::vector::style::{FillChoiceUI, Gradient, GradientSpreadMethod, GradientType, GradientUI, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation}; use graphene_std::vector::style::{FillChoiceUI, Gradient, GradientSpreadMethod, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation};
use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification};
use graphene_std::{NodeParameter, ParameterRef}; use graphene_std::{NodeParameter, ParameterRef};
@@ -1158,7 +1158,7 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button:
// Add the color input // Add the color input
let widget_value = match &**tagged_value { let widget_value = match &**tagged_value {
TaggedValue::Color(color) => FillChoiceUI::Solid(SRGBA8::from(*color)), TaggedValue::Color(color) => FillChoiceUI::Solid(SRGBA8::from(*color)),
TaggedValue::Gradient(stops) => FillChoiceUI::Gradient(GradientUI::from(stops)), TaggedValue::Gradient(stops) => FillChoiceUI::Gradient(GradientStops::from(stops)),
value if value.is_no_paint() => FillChoiceUI::None, value if value.is_no_paint() => FillChoiceUI::None,
x => { x => {
warn!("Color {x:?}"); warn!("Color {x:?}");
@@ -1175,7 +1175,7 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button:
FillChoiceUI::Gradient(gradient_ui) => TaggedValue::Gradient(Gradient::from(gradient_ui)), FillChoiceUI::Gradient(gradient_ui) => TaggedValue::Gradient(Gradient::from(gradient_ui)),
} }
} else if matches!(&**tagged_value, TaggedValue::Gradient(_)) { } else if matches!(&**tagged_value, TaggedValue::Gradient(_)) {
|input| TaggedValue::Gradient(input.value.as_gradient().map(Gradient::from).unwrap_or_default()) |input| TaggedValue::Gradient(input.value.as_gradient().map(Gradient::from).unwrap_or_else(Gradient::black_to_white))
} else { } else {
|input| TaggedValue::Color(input.value.as_solid().map(Color::from).unwrap_or(Color::TRANSPARENT)) |input| TaggedValue::Color(input.value.as_solid().map(Color::from).unwrap_or(Color::TRANSPARENT))
}; };
@@ -1256,20 +1256,12 @@ pub fn query_assign_colors_randomize(node_id: NodeId, context: &NodePropertiesCo
/// 2-stop black-to-white gradient track for spectrum sliders that map a value to a grayscale axis. /// 2-stop black-to-white gradient track for spectrum sliders that map a value to a grayscale axis.
fn bw_track() -> Gradient { fn bw_track() -> Gradient {
Gradient { Gradient::from(vec![Color::BLACK, Color::WHITE])
position: vec![0., 1.],
midpoint: vec![0.5, 0.5],
color: vec![Color::BLACK, Color::WHITE],
}
} }
/// 3-stop black-to-color-to-white gradient track for spectrum sliders that map a value to a hue's full luminance range. /// 3-stop black-to-color-to-white gradient track for spectrum sliders that map a value to a hue's full luminance range.
fn color_track(color: Color) -> Gradient { fn color_track(color: Color) -> Gradient {
Gradient { Gradient::from(vec![Color::BLACK, color, Color::WHITE])
position: vec![0., 0.5, 1.],
midpoint: vec![0.5; 3],
color: vec![Color::BLACK, color, Color::WHITE],
}
} }
pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> { pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
@@ -1300,11 +1292,8 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node
let contrast_min = if use_classic_value { -100. } else { -50. }; let contrast_min = if use_classic_value { -100. } else { -50. };
let zero_position = -contrast_min / (100. - contrast_min); let zero_position = -contrast_min / (100. - contrast_min);
let contrast_track = Gradient { let mut contrast_track = Gradient::from(vec![Color::MIDDLE_GRAY, Color::BLACK, Color::MIDDLE_GRAY]);
position: vec![0., zero_position, 1.], contrast_track.set_positions(&[0., zero_position, 1.]);
midpoint: vec![0.5; 3],
color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), Color::BLACK, Color::from_rgbf32_unchecked(0.5, 0.5, 0.5)],
};
let contrast = spectrum_slider_row( let contrast = spectrum_slider_row(
node_id, node_id,
context, context,
@@ -1333,7 +1322,7 @@ pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesCon
// (parameter, marker handle color, default percentage for double-click reset) // (parameter, marker handle color, default percentage for double-click reset)
let input_range_params = [ let input_range_params = [
(ShadowsInput.into(), Color::BLACK, 0.), (ShadowsInput.into(), Color::BLACK, 0.),
(MidtonesInput.into(), Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), 50.), (MidtonesInput.into(), Color::MIDDLE_GRAY, 50.),
(HighlightsInput.into(), Color::WHITE, 100.), (HighlightsInput.into(), Color::WHITE, 100.),
]; ];
let output_range_params = [(OutputMinimumsInput.into(), Color::BLACK, 0.), (OutputMaximumsInput.into(), Color::WHITE, 100.)]; let output_range_params = [(OutputMinimumsInput.into(), Color::BLACK, 0.), (OutputMaximumsInput.into(), Color::WHITE, 100.)];
@@ -1397,7 +1386,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
// Build the shared spectrum widget (placed on the first non-exposed row) // Build the shared spectrum widget (placed on the first non-exposed row)
let spectrum_widget = (!spectrum_markers.is_empty()).then(|| { let spectrum_widget = (!spectrum_markers.is_empty()).then(|| {
SpectrumInput::new(GradientUI::from(&bw_track())) SpectrumInput::new(GradientStops::from(&bw_track()))
.markers(spectrum_markers) .markers(spectrum_markers)
.show_midpoints(false) .show_midpoints(false)
.allow_insert(false) .allow_insert(false)
@@ -1495,17 +1484,9 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope
let saturated_current_hue = Color::from_hsva(marker_hue, 1., 1., 1.); let saturated_current_hue = Color::from_hsva(marker_hue, 1., 1., 1.);
// Hue: cyclic rainbow // Hue: cyclic rainbow
let hue_track = Gradient { let hue_track = Gradient::from(vec![Color::RED, Color::YELLOW, Color::GREEN, Color::CYAN, Color::BLUE, Color::MAGENTA, Color::RED]);
position: vec![0., 1. / 6., 2. / 6., 3. / 6., 4. / 6., 5. / 6., 1.],
midpoint: vec![0.5; 7],
color: vec![Color::RED, Color::YELLOW, Color::GREEN, Color::CYAN, Color::BLUE, Color::MAGENTA, Color::RED],
};
// Saturation: gray to the fully saturated current hue // Saturation: gray to the fully saturated current hue
let saturation_track = Gradient { let saturation_track = Gradient::from(vec![Color::MIDDLE_GRAY, saturated_current_hue]);
position: vec![0., 1.],
midpoint: vec![0.5, 0.5],
color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), saturated_current_hue],
};
// Lightness: black to white // Lightness: black to white
let lightness_track = bw_track(); let lightness_track = bw_track();
@@ -1577,7 +1558,7 @@ fn spectrum_slider_row(
let position_to_value = move |position: f64| value_min + position * value_range; let position_to_value = move |position: f64| value_min + position * value_range;
row.push( row.push(
SpectrumInput::new(GradientUI::from(&track)) SpectrumInput::new(GradientStops::from(&track))
.markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)])
.show_midpoints(false) .show_midpoints(false)
.allow_insert(false) .allow_insert(false)
@@ -1641,11 +1622,7 @@ pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodeProperties
pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> { pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::vibrance::*; use graphene_std::raster::vibrance::*;
let track = Gradient { let track = Gradient::from(vec![Color::MIDDLE_GRAY, Color::RED]);
position: vec![0., 1.],
midpoint: vec![0.5, 0.5],
color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), Color::RED],
};
vec![spectrum_slider_row( vec![spectrum_slider_row(
node_id, node_id,
context, context,
@@ -2474,11 +2451,11 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
}; };
let backup_stops = match document_node.input_value(BackupGradientInput) { let backup_stops = match document_node.input_value(BackupGradientInput) {
Some(TaggedValue::Gradient(stops)) => stops.clone(), Some(TaggedValue::Gradient(stops)) => stops.clone(),
_ => Gradient::default(), _ => Gradient::black_to_white(),
}; };
(backup_color, backup_stops) (backup_color, backup_stops)
} }
Err(_) => (None, Gradient::default()), Err(_) => (None, Gradient::black_to_white()),
}; };
match &fill { match &fill {
@@ -2504,7 +2481,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
FillChoiceUI::None FillChoiceUI::None
} }
} }
ResolvedFill::Gradient { gradient: stops, .. } => FillChoiceUI::Gradient(GradientUI::from(stops)), ResolvedFill::Gradient { gradient: stops, .. } => FillChoiceUI::Gradient(GradientStops::from(stops)),
ResolvedFill::Other => FillChoiceUI::None, ResolvedFill::Other => FillChoiceUI::None,
}; };
@@ -800,4 +800,13 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() {
matches!(transform, Some(TaggedValue::DAffine2(_))), matches!(transform, Some(TaggedValue::DAffine2(_))),
"the transform input should hold a matrix, but became {transform:?}" "the transform input should hold a matrix, but became {transform:?}"
); );
// The Sample Gradient parameter held the tuple-form stops, which parse as the stops value with even positions elided
let sample_gradient_node = &network.nodes[&graph_craft::document::NodeId(2)];
let stops = sample_gradient_node.input_value(graphene_std::math_nodes::sample_gradient::GradientInput);
let Some(TaggedValue::Gradient(stops)) = stops else {
panic!("the legacy stops parameter should become a gradient stops value, but became {stops:?}");
};
assert_eq!(stops.len(), 2);
assert!(!stops.has_position_attribute(), "even legacy tuple positions should elide rather than materialize");
} }
@@ -1814,7 +1814,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 9), old_inputs[4].clone(), network_path); document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 9), old_inputs[4].clone(), network_path);
} }
// TODO: Eventually remove this migration document upgrade code // TODO: Eventually remove this document upgrade code
// A legacy "no color" on a plain color connector (`TaggedValue::no_paint()` restored by the deserializer) becomes a color, // A legacy "no color" on a plain color connector (`TaggedValue::no_paint()` restored by the deserializer) becomes a color,
// since only paint connectors keep the no-paint choice // since only paint connectors keep the no-paint choice
{ {
@@ -323,7 +323,43 @@ pub fn get_gradient_stops(layer: LayerNodeIdentifier, network_interface: &NodeNe
let TaggedValue::Gradient(stops) = gradient_value_node.input(graphene_std::math_nodes::gradient_value::GradientInput)?.as_value()? else { let TaggedValue::Gradient(stops) = gradient_value_node.input(graphene_std::math_nodes::gradient_value::GradientInput)?.as_value()? else {
return None; return None;
}; };
Some(stops.clone()) let mut stops = stops.clone();
// The chain's stop placement comes from the closest-to-layer 'Gradient Positions'/'Gradient Midpoints' nodes,
// matching the runtime where each later node overwrites the whole attribute
let target_input = gradient_chain_target_input(layer, network_interface);
let walk_from = network_interface.upstream_output_connector(&target_input, &[])?.node_id()?;
let positions_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_positions::IDENTIFIER);
let midpoints_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_midpoints::IDENTIFIER);
let (mut positions_pending, mut midpoints_pending) = (true, true);
for node_id in network_interface
.upstream_flow_back_from_nodes(vec![walk_from], &[], FlowType::HorizontalFlow)
.take_while(|node_id| !network_interface.is_layer(node_id, &[]))
{
let Some(reference) = network_interface.reference(&node_id, &[]) else { continue };
let node = network_interface.document_network().nodes.get(&node_id);
if positions_pending && reference == positions_reference {
positions_pending = false;
if let Some(TaggedValue::F64Array(positions)) = node
.and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput))
.and_then(|input| input.as_value())
{
stops.set_positions(positions);
}
} else if midpoints_pending && reference == midpoints_reference {
midpoints_pending = false;
if let Some(TaggedValue::F64Array(midpoints)) = node
.and_then(|node| node.input(graphene_std::math_nodes::gradient_midpoints::MidpointsInput))
.and_then(|input| input.as_value())
{
stops.set_midpoints(midpoints);
}
}
}
Some(stops)
} }
/// Compute the transform from a gradient's local space to viewport space for the given layer. For a `List<Gradient>` /// Compute the transform from a gradient's local space to viewport space for the given layer. For a `List<Gradient>`
@@ -629,6 +665,7 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn
let TaggedValue::Gradient(stops) = fill_node.input(fill::FillInput)?.as_value()? else { let TaggedValue::Gradient(stops) = fill_node.input(fill::FillInput)?.as_value()? else {
return None; return None;
}; };
let stops = stops.clone();
let gradient_type = match fill_node.input(fill::GradientTypeInput).and_then(|input| input.as_value()) { let gradient_type = match fill_node.input(fill::GradientTypeInput).and_then(|input| input.as_value()) {
Some(&TaggedValue::GradientType(value)) => value, Some(&TaggedValue::GradientType(value)) => value,
_ => GradientType::default(), _ => GradientType::default(),
@@ -646,7 +683,7 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn
}; };
Some(FillNodeGradient { Some(FillNodeGradient {
stops: stops.clone(), stops,
gradient_type, gradient_type,
spread_method, spread_method,
transform, transform,
@@ -16,7 +16,7 @@ use glam::DMat2;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graphene_std::color::SRGBA8; use graphene_std::color::SRGBA8;
use graphene_std::raster::color::Color; use graphene_std::raster::color::Color;
use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientSpreadMethod, GradientStop, GradientType, GradientUI, build_transform_with_y_preservation}; use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType, build_transform_with_y_preservation};
#[derive(Default, ExtractField)] #[derive(Default, ExtractField)]
pub struct GradientTool { pub struct GradientTool {
@@ -53,7 +53,7 @@ pub enum GradientToolMessage {
CommitTransactionForColorStop, CommitTransactionForColorStop,
CloseStopColorPicker, CloseStopColorPicker,
UpdateStopColor { color: Color }, UpdateStopColor { color: Color },
UpdateStops { stops: GradientUI }, UpdateStops { stops: GradientStops<SRGBA8> },
UpdateOptions { options: GradientOptionsUpdate }, UpdateOptions { options: GradientOptionsUpdate },
} }
@@ -138,9 +138,9 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
ToolMessage::Gradient(GradientToolMessage::UpdateStopColor { color }) => { ToolMessage::Gradient(GradientToolMessage::UpdateStopColor { color }) => {
if let Some(stop_index) = self.data.color_picker_editing_color_stop if let Some(stop_index) = self.data.color_picker_editing_color_stop
&& let Some(selected_gradient) = &mut self.data.selected_gradient && let Some(selected_gradient) = &mut self.data.selected_gradient
&& stop_index < selected_gradient.gradient.color.len() && stop_index < selected_gradient.gradient.len()
{ {
selected_gradient.gradient.color[stop_index] = color; selected_gradient.gradient.set_color(stop_index, color);
selected_gradient.render_gradient(responses); selected_gradient.render_gradient(responses);
responses.add(PropertiesPanelMessage::Refresh); responses.add(PropertiesPanelMessage::Refresh);
} }
@@ -546,15 +546,15 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &Gradient, mouse: DVec2)
// Don't insert when clicking near a (currently visible) midpoint diamond // Don't insert when clicking near a (currently visible) midpoint diamond
let line_length = start.distance(end); let line_length = start.distance(end);
for i in 0..stops.position.len().saturating_sub(1) { for i in 0..stops.len().saturating_sub(1) {
let left = stops.position[i]; let left = stops.position(i);
let right = stops.position[i + 1]; let right = stops.position(i + 1);
if midpoint_hidden_by_proximity(left, right, line_length) { if midpoint_hidden_by_proximity(left, right, line_length) {
continue; continue;
} }
let midpoint_pos = left + stops.midpoint[i] * (right - left); let midpoint_pos = left + stops.midpoint(i) * (right - left);
let midpoint_viewport = start.lerp(end, midpoint_pos); let midpoint_viewport = start.lerp(end, midpoint_pos);
if midpoint_viewport.distance_squared(mouse) < GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2) { if midpoint_viewport.distance_squared(mouse) < GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2) {
return None; return None;
@@ -675,7 +675,7 @@ impl SelectedGradient {
GradientDragTarget::New => { GradientDragTarget::New => {
self.appearance.transform = create_new_gradient_transform(self.gradient_space_transform.inverse().transform_point2(drag_start), local_mouse); self.appearance.transform = create_new_gradient_transform(self.gradient_space_transform.inverse().transform_point2(drag_start), local_mouse);
} }
GradientDragTarget::Stop(s) => { GradientDragTarget::Stop(stop) => {
let document_to_viewport = snap_data.document.metadata().document_to_viewport; let document_to_viewport = snap_data.document.metadata().document_to_viewport;
let (viewport_start, viewport_end) = self.viewport_handle_positions(); let (viewport_start, viewport_end) = self.viewport_handle_positions();
@@ -722,16 +722,16 @@ impl SelectedGradient {
let min_gap = GRADIENT_STOP_MIN_VIEWPORT_GAP / line_length; let min_gap = GRADIENT_STOP_MIN_VIEWPORT_GAP / line_length;
let last_index = self.gradient.len() - 1; let last_index = self.gradient.len() - 1;
let has_other_stop_at_zero = s != 0 && self.gradient.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.); let has_other_stop_at_zero = stop != 0 && !self.gradient.is_empty() && self.gradient.position(0).abs() < f64::EPSILON * 1000.;
let has_other_stop_at_one = s != last_index && self.gradient.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.); let has_other_stop_at_one = stop != last_index && !self.gradient.is_empty() && (1. - self.gradient.position(last_index)).abs() < f64::EPSILON * 1000.;
let left_bound = if has_other_stop_at_zero { min_gap } else { 0. }; let left_bound = if has_other_stop_at_zero { min_gap } else { 0. };
let right_bound = if has_other_stop_at_one { 1. - min_gap } else { 1. }; let right_bound = if has_other_stop_at_one { 1. - min_gap } else { 1. };
let clamped = new_pos.clamp(left_bound, right_bound); let clamped = new_pos.clamp(left_bound, right_bound);
self.gradient.position[s] = clamped; self.gradient.set_position(stop, clamped);
let new_position = self.gradient.position[s]; let new_position = clamped;
let new_color = self.gradient.color[s]; let new_color = self.gradient.color(stop).unwrap_or(Color::BLACK);
self.gradient.sort(); self.gradient.sort();
if let Some(new_index) = self.gradient.iter().position(|s| s.position == new_position && s.color == new_color) { if let Some(new_index) = self.gradient.iter().position(|s| s.position == new_position && s.color == new_color) {
@@ -781,12 +781,12 @@ impl SelectedGradient {
} }
// Convert to a midpoint ratio within the interval between the two surrounding stops // Convert to a midpoint ratio within the interval between the two surrounding stops
let left_stop = self.gradient.position[midpoint_index]; let left_stop = self.gradient.position(midpoint_index);
let right_stop = self.gradient.position[midpoint_index + 1]; let right_stop = self.gradient.position(midpoint_index + 1);
let range = right_stop - left_stop; let range = right_stop - left_stop;
if range > 0. { if range > 0. {
let midpoint_ratio = ((full_pos - left_stop) / range).clamp(GRADIENT_MIDPOINT_MIN, GRADIENT_MIDPOINT_MAX); let midpoint_ratio = ((full_pos - left_stop) / range).clamp(GRADIENT_MIDPOINT_MIN, GRADIENT_MIDPOINT_MAX);
self.gradient.midpoint[midpoint_index] = midpoint_ratio; self.gradient.set_midpoint(midpoint_index, midpoint_ratio);
} }
} }
} }
@@ -819,9 +819,17 @@ impl SelectedGradient {
} }
} }
/// Send the four per-attribute graph operations that mirror the in-memory `Gradient` onto the chain feeding the layer. /// Send the per-attribute graph operations that mirror the in-memory `Gradient` onto the chain feeding the layer.
fn dispatch_gradient_chain_writes(layer: LayerNodeIdentifier, gradient: &Gradient, appearance: GradientAppearance, responses: &mut VecDeque<Message>) { fn dispatch_gradient_chain_writes(layer: LayerNodeIdentifier, gradient: &Gradient, appearance: GradientAppearance, responses: &mut VecDeque<Message>) {
responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: gradient.clone() }); responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: gradient.clone() });
responses.add(GraphOperationMessage::GradientPositionsSet {
layer,
positions: gradient.nondefault_positions().unwrap_or_default(),
});
responses.add(GraphOperationMessage::GradientMidpointsSet {
layer,
midpoints: gradient.nondefault_midpoints().unwrap_or_default(),
});
responses.add(GraphOperationMessage::GradientTransformSet { responses.add(GraphOperationMessage::GradientTransformSet {
layer, layer,
transform: appearance.transform, transform: appearance.transform,
@@ -933,12 +941,12 @@ impl Fsm for GradientToolFsmState {
SRGBA8::from(color).to_css_hex() SRGBA8::from(color).to_css_hex()
} }
let start_hex = gradient.color.first().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); let start_hex = gradient.color(0).map(color_to_hex).unwrap_or(String::from(COLOR_OVERLAY_BLUE));
let end_hex = gradient.color.last().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); let end_hex = gradient.color(gradient.len().saturating_sub(1)).map(color_to_hex).unwrap_or(String::from(COLOR_OVERLAY_BLUE));
// Check if the first/last stops are at position ~0/~1 (rendered as the endpoint dots rather than as separate stops) // Check if the first/last stops are at position ~0/~1 (rendered as the endpoint dots rather than as separate stops)
let first_at_start = gradient.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.); let first_at_start = !gradient.is_empty() && gradient.position(0).abs() < f64::EPSILON * 1000.;
let last_at_end = gradient.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.); let last_at_end = !gradient.is_empty() && (1. - gradient.position(gradient.len() - 1)).abs() < f64::EPSILON * 1000.;
overlay_context.line(start, end, None, None); overlay_context.line(start, end, None, None);
@@ -1029,15 +1037,15 @@ impl Fsm for GradientToolFsmState {
let line_angle = (end - start).to_angle(); let line_angle = (end - start).to_angle();
let line_length = start.distance(end); let line_length = start.distance(end);
let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2); let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2);
for i in 0..gradient.position.len().saturating_sub(1) { for i in 0..gradient.len().saturating_sub(1) {
let left = gradient.position[i]; let left = gradient.position(i);
let right = gradient.position[i + 1]; let right = gradient.position(i + 1);
if midpoint_hidden_by_proximity(left, right, line_length) { if midpoint_hidden_by_proximity(left, right, line_length) {
continue; continue;
} }
let midpoint_pos = left + gradient.midpoint[i] * (right - left); let midpoint_pos = left + gradient.midpoint(i) * (right - left);
let midpoint_viewport = start.lerp(end, midpoint_pos); let midpoint_viewport = start.lerp(end, midpoint_pos);
let emphasis = if dragging == Some(GradientDragTarget::Midpoint(i)) { let emphasis = if dragging == Some(GradientDragTarget::Midpoint(i)) {
@@ -1098,9 +1106,9 @@ impl Fsm for GradientToolFsmState {
// The gradient space transform has be recalculated as the saved transform in SelectedGradient may become stale by panning/zooming during the rendering of the overlay. // The gradient space transform has be recalculated as the saved transform in SelectedGradient may become stale by panning/zooming during the rendering of the overlay.
let transform = gradient_space_transform(layer, document) * selected_gradient.appearance.transform; let transform = gradient_space_transform(layer, document) * selected_gradient.appearance.transform;
let gradient = &selected_gradient.gradient; let gradient = &selected_gradient.gradient;
if stop_index < gradient.position.len() { if stop_index < gradient.len() {
let color = gradient.color[stop_index]; let color = gradient.color(stop_index).unwrap_or(Color::BLACK);
let position = gradient.position[stop_index]; let position = gradient.position(stop_index);
let start = transform.transform_point2(DVec2::ZERO); let start = transform.transform_point2(DVec2::ZERO);
let end = transform.transform_point2(DVec2::X); let end = transform.transform_point2(DVec2::X);
let position = start.lerp(end, position).into(); let position = start.lerp(end, position).into();
@@ -1133,20 +1141,21 @@ impl Fsm for GradientToolFsmState {
{ {
match selected_gradient.dragging { match selected_gradient.dragging {
GradientDragTarget::Midpoint(index) => { GradientDragTarget::Midpoint(index) => {
selected_gradient.gradient.midpoint[index] = 0.5; selected_gradient.gradient.reset_midpoint(index);
selected_gradient.render_gradient(responses); selected_gradient.render_gradient(responses);
responses.add(PropertiesPanelMessage::Refresh); responses.add(PropertiesPanelMessage::Refresh);
} }
GradientDragTarget::Start | GradientDragTarget::End | GradientDragTarget::Stop(_) => { GradientDragTarget::Start | GradientDragTarget::End | GradientDragTarget::Stop(_) => {
// Find the stop index from the drag target // Find the stop index from the drag target
let gradient = &selected_gradient.gradient;
let stop_index = match selected_gradient.dragging { let stop_index = match selected_gradient.dragging {
GradientDragTarget::Stop(i) => Some(i), GradientDragTarget::Stop(i) => Some(i),
GradientDragTarget::Start => selected_gradient.gradient.position.iter().position(|&p| p.abs() < f64::EPSILON * 1000.), GradientDragTarget::Start => (0..gradient.len()).position(|i| gradient.position(i).abs() < f64::EPSILON * 1000.),
GradientDragTarget::End => selected_gradient.gradient.position.iter().position(|&p| (1. - p).abs() < f64::EPSILON * 1000.), GradientDragTarget::End => (0..gradient.len()).position(|i| (1. - gradient.position(i)).abs() < f64::EPSILON * 1000.),
_ => None, _ => None,
}; };
if let Some(stop_index) = stop_index if let Some(stop_index) = stop_index
&& stop_index < selected_gradient.gradient.color.len() && stop_index < selected_gradient.gradient.len()
{ {
// Dismiss any existing color picker first // Dismiss any existing color picker first
if tool_data.color_picker_editing_color_stop.is_some() && tool_data.color_picker_transaction_open { if tool_data.color_picker_editing_color_stop.is_some() && tool_data.color_picker_transaction_open {
@@ -1154,11 +1163,11 @@ impl Fsm for GradientToolFsmState {
tool_data.color_picker_transaction_open = false; tool_data.color_picker_transaction_open = false;
} }
let stop_pos = selected_gradient.gradient.position[stop_index]; let stop_pos = selected_gradient.gradient.position(stop_index);
let (start, end) = selected_gradient.viewport_handle_positions(); let (start, end) = selected_gradient.viewport_handle_positions();
let viewport_pos = start.lerp(end, stop_pos); let viewport_pos = start.lerp(end, stop_pos);
let position = viewport_pos.into(); let position = viewport_pos.into();
let color = selected_gradient.gradient.color[stop_index]; let color = selected_gradient.gradient.color(stop_index).unwrap_or(Color::BLACK);
tool_data.color_picker_editing_color_stop = Some(stop_index); tool_data.color_picker_editing_color_stop = Some(stop_index);
responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: color.into(), position }); responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: color.into(), position });
} }
@@ -1197,7 +1206,7 @@ impl Fsm for GradientToolFsmState {
match selected_gradient.dragging { match selected_gradient.dragging {
GradientDragTarget::Start => { GradientDragTarget::Start => {
// Only delete if there's a real color stop at position ~0 (not the endpoint of the line which isn't itself a color stop) // Only delete if there's a real color stop at position ~0 (not the endpoint of the line which isn't itself a color stop)
if selected_gradient.gradient.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.) { if !selected_gradient.gradient.is_empty() && selected_gradient.gradient.position(0).abs() < f64::EPSILON * 1000. {
selected_gradient.gradient.remove(0); selected_gradient.gradient.remove(0);
} else { } else {
responses.add(DocumentMessage::AbortTransaction); responses.add(DocumentMessage::AbortTransaction);
@@ -1206,7 +1215,7 @@ impl Fsm for GradientToolFsmState {
} }
GradientDragTarget::End => { GradientDragTarget::End => {
// Only delete if there's a real color stop at position ~1 (not the endpoint of the line which isn't itself a color stop) // Only delete if there's a real color stop at position ~1 (not the endpoint of the line which isn't itself a color stop)
if selected_gradient.gradient.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.) { if !selected_gradient.gradient.is_empty() && (1. - selected_gradient.gradient.position(selected_gradient.gradient.len() - 1)).abs() < f64::EPSILON * 1000. {
let _ = selected_gradient.gradient.pop(); let _ = selected_gradient.gradient.pop();
} else { } else {
responses.add(DocumentMessage::AbortTransaction); responses.add(DocumentMessage::AbortTransaction);
@@ -1221,7 +1230,7 @@ impl Fsm for GradientToolFsmState {
selected_gradient.gradient.remove(index); selected_gradient.gradient.remove(index);
} }
GradientDragTarget::Midpoint(index) => { GradientDragTarget::Midpoint(index) => {
selected_gradient.gradient.midpoint[index] = 0.5; selected_gradient.gradient.reset_midpoint(index);
selected_gradient.render_gradient(responses); selected_gradient.render_gradient(responses);
responses.add(DocumentMessage::CommitTransaction); responses.add(DocumentMessage::CommitTransaction);
@@ -1238,7 +1247,7 @@ impl Fsm for GradientToolFsmState {
} else if let Some(layer) = selected_gradient.layer { } else if let Some(layer) = selected_gradient.layer {
responses.add(GraphOperationMessage::FillColorSet { responses.add(GraphOperationMessage::FillColorSet {
layer, layer,
color: Some(selected_gradient.gradient.color[0]), color: Some(selected_gradient.gradient.color(0).unwrap_or(Color::BLACK)),
}); });
} }
responses.add(DocumentMessage::CommitTransaction); responses.add(DocumentMessage::CommitTransaction);
@@ -1247,17 +1256,17 @@ impl Fsm for GradientToolFsmState {
} }
// Find the minimum and maximum positions // Find the minimum and maximum positions
let min_position = selected_gradient.gradient.position.iter().copied().reduce(f64::min).expect("No min"); let positions = selected_gradient.gradient.positions();
let max_position = selected_gradient.gradient.position.iter().copied().reduce(f64::max).expect("No max"); let min_position = positions.iter().copied().reduce(f64::min).expect("No min");
let max_position = positions.iter().copied().reduce(f64::max).expect("No max");
let gradient_transform = selected_gradient.appearance.transform; let gradient_transform = selected_gradient.appearance.transform;
let (local_start, local_end) = (gradient_transform.transform_point2(DVec2::ZERO), gradient_transform.transform_point2(DVec2::X)); let (local_start, local_end) = (gradient_transform.transform_point2(DVec2::ZERO), gradient_transform.transform_point2(DVec2::X));
selected_gradient.appearance.transform = build_transform_with_y_preservation(gradient_transform, local_start.lerp(local_end, min_position), local_start.lerp(local_end, max_position)); selected_gradient.appearance.transform = build_transform_with_y_preservation(gradient_transform, local_start.lerp(local_end, min_position), local_start.lerp(local_end, max_position));
// Remap the positions // Remap the positions
for position in selected_gradient.gradient.position.iter_mut() { let remapped: Vec<f64> = positions.into_iter().map(|position| (position - min_position) / (max_position - min_position)).collect();
*position = (*position - min_position) / (max_position - min_position); selected_gradient.gradient.set_positions(&remapped);
}
// Render the new gradient // Render the new gradient
selected_gradient.render_gradient(responses); selected_gradient.render_gradient(responses);
@@ -1336,19 +1345,19 @@ impl Fsm for GradientToolFsmState {
if drag_hint.is_none() { if drag_hint.is_none() {
let line_length = start.distance(end); let line_length = start.distance(end);
let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2); let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2);
for i in 0..gradient.position.len().saturating_sub(1) { for i in 0..gradient.len().saturating_sub(1) {
let left = gradient.position[i]; let left = gradient.position(i);
let right = gradient.position[i + 1]; let right = gradient.position(i + 1);
if midpoint_hidden_by_proximity(left, right, line_length) { if midpoint_hidden_by_proximity(left, right, line_length) {
continue; continue;
} }
let midpoint_pos = left + gradient.midpoint[i] * (right - left); let midpoint_pos = left + gradient.midpoint(i) * (right - left);
let midpoint_viewport = start.lerp(end, midpoint_pos); let midpoint_viewport = start.lerp(end, midpoint_pos);
if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance {
let resettable = midpoint_is_resettable(gradient.midpoint[i]); let resettable = midpoint_is_resettable(gradient.midpoint(i));
drag_hint = Some(GradientDragHintState::Midpoint { resettable }); drag_hint = Some(GradientDragHintState::Midpoint { resettable });
tool_data.selected_gradient = Some(SelectedGradient { tool_data.selected_gradient = Some(SelectedGradient {
@@ -1378,7 +1387,7 @@ impl Fsm for GradientToolFsmState {
} }
} }
if let Some((_, index)) = best { if let Some((_, index)) = best {
let stop_position = gradient.position[index]; let stop_position = gradient.position(index);
// Stops at position 0 or 1 are locked endpoints: dragging moves the // Stops at position 0 or 1 are locked endpoints: dragging moves the
// gradient line endpoint geometry (start/end) instead of stop position // gradient line endpoint geometry (start/end) instead of stop position
let drag_target = if stop_position.abs() < f64::EPSILON * 1000. { let drag_target = if stop_position.abs() < f64::EPSILON * 1000. {
@@ -1603,9 +1612,9 @@ impl Fsm for GradientToolFsmState {
tool_data.snap_manager.cleanup(responses); tool_data.snap_manager.cleanup(responses);
// Clear the selection if we were dragging an endpoint of the gradient which isn't a stop // Clear the selection if we were dragging an endpoint of the gradient which isn't a stop
if tool_data.selected_gradient.as_ref().is_some_and(|s| match s.dragging { if tool_data.selected_gradient.as_ref().is_some_and(|selected| match selected.dragging {
GradientDragTarget::Start => !s.gradient.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.), GradientDragTarget::Start => selected.gradient.is_empty() || selected.gradient.position(0).abs() >= f64::EPSILON * 1000.,
GradientDragTarget::End => !s.gradient.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.), GradientDragTarget::End => selected.gradient.is_empty() || (1. - selected.gradient.position(selected.gradient.len() - 1)).abs() >= f64::EPSILON * 1000.,
_ => false, _ => false,
}) { }) {
tool_data.selected_gradient = None; tool_data.selected_gradient = None;
@@ -1768,18 +1777,18 @@ fn detect_hover_target(mouse: DVec2, document: &DocumentMessageHandler) -> Gradi
let line_length = start.distance(end); let line_length = start.distance(end);
// Check midpoint diamonds first (smaller hit area, higher priority) // Check midpoint diamonds first (smaller hit area, higher priority)
for i in 0..gradient.position.len().saturating_sub(1) { for i in 0..gradient.len().saturating_sub(1) {
let left = gradient.position[i]; let left = gradient.position(i);
let right = gradient.position[i + 1]; let right = gradient.position(i + 1);
if midpoint_hidden_by_proximity(left, right, line_length) { if midpoint_hidden_by_proximity(left, right, line_length) {
continue; continue;
} }
let midpoint_position = left + gradient.midpoint[i] * (right - left); let midpoint_position = left + gradient.midpoint(i) * (right - left);
let midpoint_viewport = start.lerp(end, midpoint_position); let midpoint_viewport = start.lerp(end, midpoint_position);
if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance {
let resettable = midpoint_is_resettable(gradient.midpoint[i]); let resettable = midpoint_is_resettable(gradient.midpoint(i));
return GradientHoverTarget::Midpoint { resettable }; return GradientHoverTarget::Midpoint { resettable };
} }
} }
@@ -1820,7 +1829,7 @@ fn compute_selected_target(tool_data: &GradientToolData) -> GradientSelectedTarg
match selected_gradient.dragging { match selected_gradient.dragging {
GradientDragTarget::Stop(_) | GradientDragTarget::Start | GradientDragTarget::End => GradientSelectedTarget::Stop, GradientDragTarget::Stop(_) | GradientDragTarget::Start | GradientDragTarget::End => GradientSelectedTarget::Stop,
GradientDragTarget::Midpoint(i) => { GradientDragTarget::Midpoint(i) => {
let resettable = selected_gradient.gradient.midpoint.get(i).is_some_and(|&midpoint_value| midpoint_is_resettable(midpoint_value)); let resettable = i < selected_gradient.gradient.len() && midpoint_is_resettable(selected_gradient.gradient.midpoint(i));
GradientSelectedTarget::Midpoint { resettable } GradientSelectedTarget::Midpoint { resettable }
} }
GradientDragTarget::New => GradientSelectedTarget::None, GradientDragTarget::New => GradientSelectedTarget::None,
@@ -2011,6 +2020,7 @@ mod test_gradient {
use crate::messages::tool::common_functionality::graph_modification_utils::get_upstream_gradient_value_node_id; use crate::messages::tool::common_functionality::graph_modification_utils::get_upstream_gradient_value_node_id;
pub use crate::test_utils::test_prelude::*; pub use crate::test_utils::test_prelude::*;
use glam::DAffine2; use glam::DAffine2;
use graph_craft::document::NodeInput;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graphene_std::color::SRGBA8; use graphene_std::color::SRGBA8;
use graphene_std::vector::style::{GradientSpreadMethod, build_transform_with_y_preservation}; use graphene_std::vector::style::{GradientSpreadMethod, build_transform_with_y_preservation};
@@ -2164,6 +2174,28 @@ mod test_gradient {
layer layer
} }
// Locks the parameter-level `#[default(Color::BLACK, Color::WHITE)]` machinery, since `Gradient::default()` is the empty list
#[tokio::test]
async fn gradient_value_node_defaults_to_black_to_white() {
let mut editor = EditorTestUtils::create();
editor.new_document().await;
let node_id = editor.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER)).await;
let document = editor.active_document();
let stops = document
.network_interface
.document_network()
.nodes
.get(&node_id)
.and_then(|node| node.input(graphene_std::math_nodes::gradient_value::GradientInput))
.and_then(|input| input.as_value())
.cloned();
let Some(TaggedValue::Gradient(stops)) = stops else {
panic!("expected a gradient default, got {stops:?}")
};
assert_eq!(stops.positions(), vec![0., 1.], "the parameter default should be the black-to-white starting gradient");
}
async fn create_fill_gradient_chain_layer(editor: &mut EditorTestUtils) -> LayerNodeIdentifier { async fn create_fill_gradient_chain_layer(editor: &mut EditorTestUtils) -> LayerNodeIdentifier {
editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await;
let document = editor.active_document(); let document = editor.active_document();
@@ -2441,7 +2473,7 @@ mod test_gradient {
let positions: Vec<f64> = stops.iter().map(|stop| stop.position).collect(); let positions: Vec<f64> = stops.iter().map(|stop| stop.position).collect();
assert_stops_at_positions(&positions, &[0., 0.25, 1.], 0.1); assert_stops_at_positions(&positions, &[0., 0.25, 1.], 0.1);
let middle_color = SRGBA8::from(stops.color[1]); let middle_color = SRGBA8::from(stops.color(1).unwrap());
// Simulate dragging the middle stop to position 0.8 // Simulate dragging the middle stop to position 0.8
let click_position = DVec2::new(25., 0.); let click_position = DVec2::new(25., 0.);
@@ -2482,9 +2514,9 @@ mod test_gradient {
assert_stops_at_positions(&updated_positions, &[0., 0.8, 1.], 0.1); assert_stops_at_positions(&updated_positions, &[0., 0.8, 1.], 0.1);
// Colors should maintain their associations with the stop points // Colors should maintain their associations with the stop points
assert_eq!(SRGBA8::from(updated_stops.color[0]), SRGBA8::from(Color::GREEN)); assert_eq!(SRGBA8::from(updated_stops.color(0).unwrap()), SRGBA8::from(Color::GREEN));
assert_eq!(SRGBA8::from(updated_stops.color[1]), middle_color); assert_eq!(SRGBA8::from(updated_stops.color(1).unwrap()), middle_color);
assert_eq!(SRGBA8::from(updated_stops.color[2]), SRGBA8::from(Color::BLUE)); assert_eq!(SRGBA8::from(updated_stops.color(2).unwrap()), SRGBA8::from(Color::BLUE));
} }
#[tokio::test] #[tokio::test]
@@ -2783,10 +2815,10 @@ mod test_gradient {
let updated = ResolvedGradient::new(updated, appearance); let updated = ResolvedGradient::new(updated, appearance);
assert_eq!(updated.stops.len(), 3, "Stop count should be preserved"); assert_eq!(updated.stops.len(), 3, "Stop count should be preserved");
assert_stops_at_positions(&updated.stops.position, &[0., 0.5, 1.], 1e-10); assert_stops_at_positions(&updated.stops.positions(), &[0., 0.5, 1.], 1e-10);
assert_eq!(SRGBA8::from(updated.stops.color[0]), SRGBA8::from(Color::RED), "First stop color should be preserved"); assert_eq!(SRGBA8::from(updated.stops.color(0).unwrap()), SRGBA8::from(Color::RED), "First stop color should be preserved");
assert_eq!(SRGBA8::from(updated.stops.color[1]), SRGBA8::from(Color::GREEN), "Middle stop color should be preserved"); assert_eq!(SRGBA8::from(updated.stops.color(1).unwrap()), SRGBA8::from(Color::GREEN), "Middle stop color should be preserved");
assert_eq!(SRGBA8::from(updated.stops.color[2]), SRGBA8::from(Color::BLUE), "Last stop color should be preserved"); assert_eq!(SRGBA8::from(updated.stops.color(2).unwrap()), SRGBA8::from(Color::BLUE), "Last stop color should be preserved");
} }
// When the gradient chain feeds a 'Fill' node's secondary input it's an unencapsulated side-branch (no layer // When the gradient chain feeds a 'Fill' node's secondary input it's an unencapsulated side-branch (no layer
@@ -2882,4 +2914,93 @@ mod test_gradient {
"the feeder's branch should shift one chain-width left to make room" "the feeder's branch should shift one chain-width left to make room"
); );
} }
#[tokio::test]
async fn chain_stop_placement_composes_through_setter_nodes() {
use crate::messages::tool::common_functionality::graph_modification_utils::get_gradient_stops;
let mut editor = EditorTestUtils::create();
editor.new_document().await;
let layer = create_gradient_list_layer(&mut editor).await;
let count_nodes = |editor: &mut EditorTestUtils, reference: &DefinitionIdentifier| {
let document = editor.active_document();
let network_interface = &document.network_interface;
network_interface
.document_network()
.nodes
.keys()
.filter(|&node_id| network_interface.reference(node_id, &[]).as_ref() == Some(reference))
.count()
};
let positions_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_positions::IDENTIFIER);
// Setter ops are update-only: without a user-authored setter node they are no-ops, since the stops value carries placement
editor.handle_message(GraphOperationMessage::GradientPositionsSet { layer, positions: vec![0., 0.25] }).await;
assert_eq!(count_nodes(&mut editor, &positions_reference), 0, "the op must not insert a Gradient Positions node");
// Wire Gradient Positions and Gradient Midpoints nodes into the chain like a user would
let gradient_value_id = {
let document = editor.active_document();
get_upstream_gradient_value_node_id(layer, &document.network_interface).expect("Gradient Value node should exist")
};
let positions_node_id = editor
.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_positions::IDENTIFIER))
.await;
let midpoints_node_id = editor
.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_midpoints::IDENTIFIER))
.await;
editor
.handle_message(NodeGraphMessage::CreateWire {
output_connector: OutputConnector::primary_output(gradient_value_id),
input_connector: InputConnector::node_at_index(positions_node_id, 0),
})
.await;
editor
.handle_message(NodeGraphMessage::CreateWire {
output_connector: OutputConnector::primary_output(positions_node_id),
input_connector: InputConnector::node_at_index(midpoints_node_id, 0),
})
.await;
editor
.handle_message(NodeGraphMessage::CreateWire {
output_connector: OutputConnector::primary_output(midpoints_node_id),
input_connector: InputConnector::layer_secondary_input(layer.to_node()),
})
.await;
// Now the ops update the existing nodes, and the chain read composes their attributes over the stops value
editor.handle_message(GraphOperationMessage::GradientPositionsSet { layer, positions: vec![0., 0.25] }).await;
editor.handle_message(GraphOperationMessage::GradientMidpointsSet { layer, midpoints: vec![0.7, 0.5] }).await;
let document = editor.active_document();
let stops = get_gradient_stops(layer, &document.network_interface).expect("the chain should resolve stops");
assert_stops_at_positions(&stops.positions(), &[0., 0.25], 1e-10);
assert_eq!(stops.midpoints(), vec![0.7, 0.5]);
// An empty update restores the default placement, overriding the value's own explicit placement at runtime
editor.handle_message(GraphOperationMessage::GradientPositionsSet { layer, positions: vec![] }).await;
let document = editor.active_document();
let stops = get_gradient_stops(layer, &document.network_interface).expect("the chain should resolve stops");
assert_stops_at_positions(&stops.positions(), &[0., 1.], 1e-10);
assert!(!stops.has_position_attribute(), "the empty setter value should clear the attribute");
// A wired setter input is procedural authorship, which the update must leave untouched rather than bake over
let number_node_id = editor.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::number_value::IDENTIFIER)).await;
editor
.handle_message(NodeGraphMessage::CreateWire {
output_connector: OutputConnector::primary_output(number_node_id),
input_connector: InputConnector::node(positions_node_id, graphene_std::math_nodes::gradient_positions::PositionsInput),
})
.await;
editor.handle_message(GraphOperationMessage::GradientPositionsSet { layer, positions: vec![0., 0.5] }).await;
let document = editor.active_document();
let positions_input = document
.network_interface
.document_network()
.nodes
.get(&positions_node_id)
.and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput))
.expect("the positions input should exist");
assert!(matches!(positions_input, NodeInput::Node { .. }), "the wired positions input must survive the baked write");
}
} }
+5 -5
View File
@@ -1,4 +1,4 @@
import type { FillChoiceUI, GradientUI, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { FillChoiceUI, GradientStops, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
// Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers // Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers
export type HSV = { h: number; s: number; v: number }; export type HSV = { h: number; s: number; v: number };
@@ -182,8 +182,8 @@ export function contrastingOutlineFactor(value: FillChoiceUI, proximityColor: st
// GRADIENT UTILITY FUNCTIONS // GRADIENT UTILITY FUNCTIONS
export function isGradientUI(value: unknown): value is GradientUI { export function isGradientStops(value: unknown): value is GradientStops<SRGBA8> {
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value; return typeof value === "object" && value !== null && "color" in value && Array.isArray(value.color);
} }
// FILL CHOICE UTILITY FUNCTIONS // FILL CHOICE UTILITY FUNCTIONS
@@ -193,7 +193,7 @@ export function fillChoiceUIColor(value: FillChoiceUI): SRGBA8 | undefined {
return undefined; return undefined;
} }
export function fillChoiceUIGradient(value: FillChoiceUI): GradientUI | undefined { export function fillChoiceUIGradient(value: FillChoiceUI): GradientStops<SRGBA8> | undefined {
if (typeof value === "object" && "Gradient" in value) return value.Gradient; if (typeof value === "object" && "Gradient" in value) return value.Gradient;
return undefined; return undefined;
} }
@@ -201,6 +201,6 @@ export function fillChoiceUIGradient(value: FillChoiceUI): GradientUI | undefine
export function parseFillChoiceUI(value: unknown): FillChoiceUI { export function parseFillChoiceUI(value: unknown): FillChoiceUI {
if (value === "None" || value === undefined || value === null) return "None"; if (value === "None" || value === undefined || value === null) return "None";
if (typeof value === "object" && value !== null && "Solid" in value && isSRgba8(value.Solid)) return { Solid: value.Solid }; if (typeof value === "object" && value !== null && "Solid" in value && isSRgba8(value.Solid)) return { Solid: value.Solid };
if (typeof value === "object" && value !== null && "Gradient" in value && isGradientUI(value.Gradient)) return { Gradient: value.Gradient }; if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStops(value.Gradient)) return { Gradient: value.Gradient };
return "None"; return "None";
} }
+39 -48
View File
@@ -2,7 +2,7 @@ use super::DocumentNode;
use crate::application_io::PlatformEditorApi; use crate::application_io::PlatformEditorApi;
use crate::application_io::resource::Resource; use crate::application_io::resource::Resource;
use crate::proto::Any as DAny; 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::color::SRGBA8;
use core_types::context::Context; use core_types::context::Context;
use core_types::gpoll::GPoll; use core_types::gpoll::GPoll;
@@ -29,7 +29,6 @@ use std::hash::Hash;
use std::str::FromStr; use std::str::FromStr;
pub use std::sync::Arc; pub use std::sync::Arc;
use text_nodes::Font; use text_nodes::Font;
use text_nodes::vector_types::GradientStop;
use vector::VectorModification; use vector::VectorModification;
pub struct TaggedValueTypeError; 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. /// Example: `TaggedValue::TypeDefault(descriptor!(String))` stores the type `String` but no specific string value.
TypeDefault(TypeDescriptor), 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. /// 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")] #[serde(alias = "F64Table", alias = "VecF64", alias = "VecF32", alias = "F64Array4")]
F64Array(Vec<f64>), 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>), 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>), BoxCorners(Vec<f64>),
/// A plain, always-present color. Aliases recover legacy on-disk shapes; a legacy `null` payload (the old "no color") /// 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`. /// 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")] #[serde(alias = "ColorTable", alias = "OptionalColor", alias = "ColorNotInTable")]
Color(Color), 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`.) /// (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")] #[serde(alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")]
Gradient(Gradient), 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. /// 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")] #[serde(alias = "BrushStrokeTable")]
BrushStrokes(Vec<BrushStroke>), 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::<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::<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::<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::<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::<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::<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::<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 // 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::<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::<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::<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::<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::<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::<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::<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 // AUTO-GENERATED VARIANTS
// ======================= // =======================
@@ -698,29 +686,13 @@ impl TaggedValue {
fn to_gradient(input: &str) -> Option<Gradient> { fn to_gradient(input: &str) -> Option<Gradient> {
// String syntax: (e.g. "000000ff, ff0000ff") // String syntax: (e.g. "000000ff, ff0000ff")
let stops = input.split(',').filter_map(|s| to_color(s.trim())).collect::<Vec<_>>(); let stops = input.split(',').filter_map(|s| to_color(s.trim())).collect::<Vec<_>>();
if stops.len() == 1 { match stops.len() {
Some(Gradient::new(vec![ 0 => {
GradientStop { log::error!("Invalid default value gradient string: {input}");
position: 0., None
midpoint: 0.5, }
color: stops[0], 1 => Some(Gradient::from(vec![stops[0], stops[0]])),
}, _ => Some(Gradient::from(stops)),
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
} }
} }
@@ -823,7 +795,7 @@ impl TaggedValue {
/// - `FillChoice` → `TaggedValue::Color` (solid), `TaggedValue::Gradient` (gradient), or `TaggedValue::no_paint()` (none) /// - `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`. /// 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")] #[cfg(feature = "loading")]
pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<MemoHash<TaggedValue>, D::Error> { pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<MemoHash<TaggedValue>, D::Error> {
use serde::Deserialize; 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())); 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>`. // The gradient tags carried several shapes over time, disambiguated here: the ancient full struct (`start`/`end` keys) becomes `LegacyGradient`,
// Disambiguate by payload shape: a Gradient struct has `start`/`end` keys; a `Gradient` has none of those (it has `position`/`midpoint`/`color`). // while the current stops struct, the old tuple list, and the legacy one-element table wrapper all parse as the stops value directly
"Gradient" if content.as_object().is_some_and(|c| c.contains_key("start") && c.contains_key("end")) => { "Gradient" | "GradientTable" | "GradientPositions" | "GradientStops" => {
let gradient: graphic_types::migrations::legacy::LegacyGradient = serde_json::from_value(content.clone()).map_err(serde::de::Error::custom)?; let table_element = content
return Ok(MemoHash::new(TaggedValue::LegacyGradient(gradient))); .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 /// glyph origin so it survives 'Index Elements' filtering. The Text tool reads this to
/// position its drag cage. /// position its drag cage.
pub EditorTextFrame("editor:text_frame"): DAffine2; 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). /// Byte offset where a regex match begins ('Regex Find All' and 'Regex Capture' text nodes).
pub Start("start"): u64; pub Start("start"): u64;
/// Byte offset where a regex match ends ('Regex Find All' and 'Regex Capture' text nodes). /// 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_CLIPPING_MASK: &str = crate::attribute::ClippingMask::NAME;
pub const ATTR_EDITOR_LAYER_PATH: &str = crate::attribute::EditorLayerPath::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_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_START: &str = crate::attribute::Start::NAME;
pub const ATTR_END: &str = crate::attribute::End::NAME; pub const ATTR_END: &str = crate::attribute::End::NAME;
pub const ATTR_NAME: &str = crate::attribute::Name::NAME; pub const ATTR_NAME: &str = crate::attribute::Name::NAME;
+2 -2
View File
@@ -89,7 +89,7 @@ struct LegacyTable<T> {
element: Vec<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> { 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 no_std_types::color::Color;
use serde::Deserialize; 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> { pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<f64>, D::Error> {
use serde::Deserialize; use serde::Deserialize;
@@ -1,7 +1,7 @@
use crate::concrete; use crate::concrete;
use crate::context::{Context, ContextImpl}; use crate::context::{Context, ContextImpl};
use crate::node::Node; use crate::node::Node;
use crate::{ContextFeature, ProtoNodeIdentifier, Type, WasmNotSend, WasmNotSync}; use crate::{Color, ContextFeature, ProtoNodeIdentifier, Type, WasmNotSend, WasmNotSync};
use dyn_any::DynAny; use dyn_any::DynAny;
use graphene_hash::CacheHash; use graphene_hash::CacheHash;
pub use no_std_types::registry::types; pub use no_std_types::registry::types;
@@ -35,6 +35,8 @@ pub struct FieldMetadata {
pub exposed: bool, pub exposed: bool,
pub widget_override: RegistryWidgetOverride, pub widget_override: RegistryWidgetOverride,
pub value_source: RegistryValueSource, 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>, pub default_type: Option<Type>,
/// The slider's suggested extent, from `#[soft(a..b)]`. Typed values may exceed it. /// The slider's suggested extent, from `#[soft(a..b)]`. Typed values may exceed it.
pub number_soft_min: Option<f64>, pub number_soft_min: Option<f64>,
@@ -17,7 +17,7 @@ pub mod migrations {
use crate::Vector; use crate::Vector;
// Storing legacy structs that are only used in document migration. // 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 { pub mod legacy {
use core_types::Color; use core_types::Color;
use dyn_any::DynAny; 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). /// 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> { pub fn migrate_to_optional_vector<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<Vector>, D::Error> {
use serde::Deserialize; 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. // `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 {} impl Eq for Color {}
// TODO: Eventually remove this migration document upgrade code // TODO: Eventually remove this document upgrade code
#[cfg(feature = "std")] #[cfg(feature = "std")]
impl serde::Serialize for Color { impl serde::Serialize for Color {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 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")] #[cfg(feature = "std")]
impl<'de> serde::Deserialize<'de> for Color { impl<'de> serde::Deserialize<'de> for Color {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 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 YELLOW: Color = Color::from_rgbf32_unchecked(1., 1., 0.);
pub const CYAN: Color = Color::from_rgbf32_unchecked(0., 1., 1.); pub const CYAN: Color = Color::from_rgbf32_unchecked(0., 1., 1.);
pub const MAGENTA: Color = Color::from_rgbf32_unchecked(1., 0., 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 { pub const TRANSPARENT: Color = Self {
red: 0., red: 0.,
green: 0., green: 0.,
@@ -127,6 +127,11 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>
stop.push_str(" />") 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. // 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) { let element_transform_inverse = if transform_is_invertible(element_transform) {
element_transform.inverse() element_transform.inverse()
+27 -14
View File
@@ -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)> { 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)?; 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 gradient_transform: DAffine2 = gradient_list.attr::<Transform>(0);
let spread_method: GradientSpreadMethod = gradient_list.attr::<SpreadMethod>(0); let spread_method: GradientSpreadMethod = gradient_list.attr::<SpreadMethod>(0);
let mut peniko_stops = peniko::ColorStops::new(); let peniko_stops = peniko_color_stops(stops);
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()),
});
}
// The unit gradient is placed by the desheared frame so a non-uniform transform produces the intended ellipse // 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)); 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 blend_mode = blend_mode_attr.to_peniko();
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let mut stops: peniko::ColorStops = peniko::ColorStops::new(); let stops = peniko_color_stops(gradient);
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 extend = match spread_method { let extend = match spread_method {
GradientSpreadMethod::Pad => peniko::Extend::Pad, GradientSpreadMethod::Pad => peniko::Extend::Pad,
@@ -36,3 +36,7 @@ serde = { workspace = true, optional = true }
tsify = { workspace = true, optional = true } tsify = { workspace = true, optional = true }
wasm-bindgen = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true }
fixedbitset = "0.5.7" fixedbitset = "0.5.7"
[dev-dependencies]
# Workspace dependencies
serde_json = { workspace = true }
+548 -206
View File
@@ -1,5 +1,6 @@
use core_types::Color; use core_types::Color;
use core_types::color::SRGBA8; use core_types::color::SRGBA8;
use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List};
use core_types::render_complexity::RenderComplexity; use core_types::render_complexity::RenderComplexity;
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
@@ -14,116 +15,127 @@ pub enum GradientType {
Radial, Radial,
} }
// TODO: Someday we could switch this to a Box[T] to avoid over-allocation /// A gradient's stops: a list of colors (linear, unassociated alpha) whose optional `position` and `midpoint`
/// A list of colors (linear, unassociated alpha) associated with positions (in the range 0 to 1) along a gradient. /// 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`).
/// Not exposed via Tsify; use [`GradientUI`] at the JS boundary. #[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] pub struct Gradient(List<Color>);
#[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>,
}
/// JS-boundary version of [`Gradient`] where stop colors are [`SRGBA8`] byte triples instead of linear-light [`Color`]. /// A gradient's per-stop parallel arrays, generic over color format: `GradientStops<Color>` is the document serialization
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] /// of `TaggedValue::Gradient`, while `GradientStops<SRGBA8>` is the JS-boundary shape used by the color picker UI.
#[derive(Debug, Clone, PartialEq, Default, DynAny)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GradientUI { pub struct GradientStops<C> {
pub position: Vec<f64>, pub color: Vec<C>,
pub midpoint: Vec<f64>, #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
pub color: Vec<SRGBA8>, #[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 { unsafe impl<C: dyn_any::StaticTypeSized> dyn_any::StaticType for GradientStops<C> {
fn from(s: &Gradient) -> Self { type Static = GradientStops<C::Static>;
}
impl From<&Gradient> for GradientStops<Color> {
fn from(gradient: &Gradient) -> Self {
Self { Self {
position: s.position.clone(), position: gradient.position_attribute(),
midpoint: s.midpoint.clone(), midpoint: gradient.midpoint_attribute(),
color: s.color.iter().map(|c| SRGBA8::from(*c)).collect(), color: gradient.0.iter_element_values().copied().collect(),
} }
} }
} }
impl From<&GradientUI> for Gradient { impl From<&Gradient> for GradientStops<SRGBA8> {
fn from(s: &GradientUI) -> Self { fn from(gradient: &Gradient) -> Self {
Self { Self {
position: s.position.clone(), position: gradient.position_attribute(),
midpoint: s.midpoint.clone(), midpoint: gradient.midpoint_attribute(),
color: s.color.iter().map(|c| Color::from(*c)).collect(), 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). /// CSS `linear-gradient(...)` string. Stops are emitted as `#rrggbbaa` hex (already gamma-encoded bytes).
pub fn to_css_linear_gradient(&self) -> String { pub fn to_css_linear_gradient(&self) -> String {
if self.position.len() <= 1 { Gradient::from(self).to_css_linear_gradient()
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})")
} }
} }
// 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 { impl<'de> serde::Deserialize<'de> for Gradient {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct NewFormat { #[serde(untagged)]
position: Vec<f64>,
midpoint: Vec<f64>,
color: Vec<Color>,
}
#[derive(serde::Deserialize)]
#[cfg_attr(feature = "serde", serde(untagged))]
enum GradientStopsFormat { enum GradientStopsFormat {
New(NewFormat), Struct(GradientStops<Color>),
Old(Vec<(f64, Color)>), Tuples(Vec<(f64, Color)>),
} }
Ok(match GradientStopsFormat::deserialize(deserializer)? { Ok(match GradientStopsFormat::deserialize(deserializer)? {
GradientStopsFormat::New(new) => Self { GradientStopsFormat::Struct(stops) => Gradient::from(stops),
position: new.position, GradientStopsFormat::Tuples(stops) => {
midpoint: new.midpoint, let position: Vec<f64> = stops.iter().map(|(p, _)| *p).collect();
color: new.color, let mut gradient = Gradient::from(stops.into_iter().map(|(_, c)| c).collect::<Vec<_>>());
}, gradient.set_positions(&position);
GradientStopsFormat::Old(stops) => { gradient.elide_default_attributes();
let count = stops.len(); gradient
Self {
position: stops.iter().map(|(p, _)| *p).collect(),
midpoint: vec![0.5; count],
color: stops.into_iter().map(|(_, c)| c).collect(),
}
} }
}) })
} }
} }
impl Default for Gradient { impl From<List<Color>> for Gradient {
fn default() -> Self { fn from(colors: List<Color>) -> Self {
Self { Self(colors)
position: vec![0., 1.], }
midpoint: vec![0.5, 0.5], }
color: vec![Color::BLACK, Color::WHITE],
} 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). /// 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 { fn apply_midpoint(t: f64, midpoint: f64) -> f64 {
let midpoint = sanitized_midpoint(midpoint);
if (midpoint - 0.5).abs() < 1e-6 { if (midpoint - 0.5).abs() < 1e-6 {
return t; return t;
} }
let midpoint = midpoint.clamp(f64::EPSILON, 1. - f64::EPSILON);
if midpoint < 0.5 { if midpoint < 0.5 {
let q = -1. / (1. - midpoint).log2(); let q = -1. / (1. - midpoint).log2();
1. - (1. - t).powf(q) 1. - (1. - t).powf(q)
@@ -162,25 +178,21 @@ pub struct GradientStopsIter<'a> {
index: usize, index: usize,
} }
impl<'a> Iterator for GradientStopsIter<'a> { impl Iterator for GradientStopsIter<'_> {
type Item = GradientStop; type Item = GradientStop;
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
if self.index >= self.stops.position.len() {
return None;
}
let stop = GradientStop { let stop = GradientStop {
position: self.stops.position[self.index], position: self.stops.position(self.index),
midpoint: self.stops.midpoint[self.index], midpoint: self.stops.midpoint(self.index),
color: self.stops.color[self.index], color: self.stops.color(self.index)?,
}; };
self.index += 1; self.index += 1;
Some(stop) Some(stop)
} }
fn size_hint(&self) -> (usize, Option<usize>) { 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)) (remaining, Some(remaining))
} }
} }
@@ -201,63 +213,215 @@ impl IntoIterator for Gradient {
type IntoIter = std::vec::IntoIter<GradientStop>; type IntoIter = std::vec::IntoIter<GradientStop>;
fn into_iter(self) -> Self::IntoIter { fn into_iter(self) -> Self::IntoIter {
self.position self.iter().collect::<Vec<_>>().into_iter()
.into_iter()
.zip(self.midpoint)
.zip(self.color)
.map(|((position, midpoint), color)| GradientStop { position, midpoint, color })
.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 { impl Gradient {
pub fn new(stops: impl IntoIterator<Item = GradientStop>) -> Self { pub fn new(stops: impl IntoIterator<Item = GradientStop>) -> Self {
let mut position = Vec::new(); let stops: Vec<GradientStop> = stops.into_iter().collect();
let mut midpoint = Vec::new(); let mut list: List<Color> = stops.iter().map(|stop| Item::new_from_element(stop.color)).collect();
let mut color = Vec::new();
for stop in stops { for (index, stop) in stops.iter().enumerate() {
position.push(stop.position); list.set_attribute(ATTR_POSITION, index, stop.position);
midpoint.push(stop.midpoint); list.set_attribute(ATTR_MIDPOINT, index, stop.midpoint);
color.push(stop.color);
} }
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 { pub fn len(&self) -> usize {
self.position.len() self.0.len()
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.position.is_empty() self.0.is_empty()
} }
pub fn iter(&self) -> GradientStopsIter<'_> { pub fn iter(&self) -> GradientStopsIter<'_> {
self.into_iter() 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. /// Remove a stop at the given index.
pub fn remove(&mut self, index: usize) { pub fn remove(&mut self, index: usize) {
self.position.remove(index); self.0 = self.reordered((0..self.len()).filter(|&i| i != index));
self.midpoint.remove(index);
self.color.remove(index);
} }
/// Remove and return the last stop's color, or `None` if empty. /// Remove and return the last stop's color, or `None` if empty.
pub fn pop(&mut self) -> Option<Color> { pub fn pop(&mut self) -> Option<Color> {
self.position.pop(); let color = self.color(self.len().checked_sub(1)?);
self.midpoint.pop(); self.0 = self.reordered(0..self.len() - 1);
self.color.pop() color
} }
/// Move the stop at `index` to a new position, re-sorting the stops by position. Returns the new index of the moved stop. /// 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 { pub fn move_stop(&mut self, index: usize, position: f64) -> usize {
if index >= self.position.len() { if index >= self.len() {
return index; return index;
} }
self.position[index] = position; self.set_position(index, position);
self.sort_returning_new_index(index) 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). /// 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. /// Returns the index where the new stop was inserted.
pub fn insert_stop(&mut self, position: f64) -> usize { pub fn insert_stop(&mut self, position: f64) -> usize {
let color = self.evaluate(position); let color = self.evaluate(position, Default::default());
let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len()); let index = (0..self.len()).position(|i| self.position(i) > position).unwrap_or(self.len());
let midpoint = index.checked_sub(1).and_then(|i| self.midpoint.get(i).copied()).unwrap_or(0.5); let midpoint = if index > 0 { self.midpoint(index - 1) } else { 0.5 };
self.position.insert(index, position); self.insert_stop_values(position, midpoint, color)
self.midpoint.insert(index, midpoint);
self.color.insert(index, color);
index
} }
/// Insert a copy of the stop at `source_index` (same color and midpoint) at `position`, keeping the stops sorted by position. /// 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. /// 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> { pub fn duplicate_stop(&mut self, source_index: usize, position: f64) -> Option<usize> {
let color = *self.color.get(source_index)?; let color = self.color(source_index)?;
let midpoint = *self.midpoint.get(source_index)?; let midpoint = self.midpoint(source_index);
let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len()); Some(self.insert_stop_values(position, midpoint, color))
self.position.insert(index, position); }
self.midpoint.insert(index, midpoint);
self.color.insert(index, color); /// Splices a new stop into the sorted position, materializing explicit positions (an arbitrary insertion breaks even distribution)
Some(index) /// 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`. /// Reset the midpoint for the interval starting at `index` to its default `0.5`.
pub fn reset_midpoint(&mut self, index: usize) { pub fn reset_midpoint(&mut self, index: usize) {
if let Some(midpoint) = self.midpoint.get_mut(index) { if self.has_midpoint_attribute() && index < self.len() {
*midpoint = 0.5; 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. /// 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 { fn sort_returning_new_index(&mut self, previous_index: usize) -> usize {
let len = self.position.len(); // An absent position attribute is an even distribution, which is already sorted
let mut indices: Vec<usize> = (0..len).collect(); if !self.has_position_attribute() {
indices.sort_by(|&a, &b| self.position[a].total_cmp(&self.position[b])); 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); 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.0 = self.reordered(indices);
self.midpoint = indices.iter().map(|&i| self.midpoint[i]).collect();
self.color = indices.iter().map(|&i| self.color[i]).collect();
new_index new_index
} }
pub fn evaluate(&self, t: f64) -> Color { /// Gradient stops as evaluation and rendering should see them: positions clamped to the 0 to 1 range
if self.position.is_empty() { /// (infinities landing at the ends, a NaN dropping its stop from sampling since it has no defined placement)
return Color::BLACK; /// 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] { for pair in stops.windows(2) {
return self.color[0]; let (a, b) = (&pair[0], &pair[1]);
} if t >= a.position && t <= b.position {
let last = self.position.len() - 1; let normalized_t = (t - a.position) / (b.position - a.position);
if t >= self.position[last] { let adjusted_t = apply_midpoint(normalized_t, a.midpoint);
return self.color[last]; return a.color.lerp(&b.color, adjusted_t as f32);
}
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);
} }
} }
@@ -332,36 +542,43 @@ impl Gradient {
} }
pub fn sort(&mut self) { pub fn sort(&mut self) {
let mut indices: Vec<usize> = (0..self.position.len()).collect(); self.sort_returning_new_index(0);
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();
} }
pub fn reversed(&self) -> Self { 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(); // Row reversal already reversed the position cells' order, each also flips across the range
let midpoint = (0..count).map(|i| if i < count - 1 { 1. - self.midpoint[count - 2 - i] } else { 0.5 }).collect::<Vec<_>>(); 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 { pub fn map_colors<F: Fn(&Color) -> Color>(&self, f: F) -> Self {
Self { let mut mapped = self.clone();
position: self.position.clone(), mapped.0.iter_element_values_mut().for_each(|color| *color = f(color));
midpoint: self.midpoint.clone(), mapped
color: self.color.iter().map(f).collect(),
}
} }
/// 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. /// 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 { pub fn to_css_linear_gradient(&self) -> String {
if self.position.len() <= 1 { if self.len() <= 1 {
let hex = self.color.first().map(|c| SRGBA8::from(*c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string()); 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%)"); return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
} }
let pieces = self let pieces = self
@@ -379,7 +596,7 @@ impl Gradient {
/// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves. /// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves.
/// ///
/// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding /// 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 /// 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 /// 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![]; return vec![];
} }
if self.position.len() == 1 { if count == 1 {
return vec![(self.position[0], self.color[0], Some(self.midpoint[0]))]; return vec![(stops[0].position, stops[0].color, Some(sanitized_midpoint(stops[0].midpoint)))];
} }
let mut result = Vec::new(); let mut result = Vec::new();
for i in 0..self.position.len() - 1 { for i in 0..count - 1 {
let pos_a = self.position[i]; let pos_a = stops[i].position;
let pos_b = self.position[i + 1]; let pos_b = stops[i + 1].position;
let color_a = self.color[i]; let color_a = stops[i].color;
let color_b = self.color[i + 1]; let color_b = stops[i + 1].color;
let midpoint = self.midpoint[i].clamp(0.01, 0.99); let midpoint = sanitized_midpoint(stops[i].midpoint);
let next_midpoint = self.midpoint[i + 1].clamp(0.01, 0.99); let next_midpoint = sanitized_midpoint(stops[i + 1].midpoint);
// Add the start stop (subsequent segments share the previous end stop) // Add the start stop (subsequent segments share the previous end stop)
if i == 0 { if i == 0 {
@@ -479,6 +698,7 @@ pub enum GradientSpreadMethod {
Pad, Pad,
Reflect, Reflect,
Repeat, Repeat,
// TODO: Add a "Clear" variant that returns transparent black outside the gradient's range
} }
impl GradientSpreadMethod { 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 { impl core_types::bounds::BoundingBox for Gradient {
fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox {
core_types::bounds::RenderBoundingBox::Infinite 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)]) 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 // 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))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Default, Debug, Clone, PartialEq, DynAny)] #[derive(Default, Debug, Clone, PartialEq, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -36,7 +36,7 @@ pub enum FillChoiceUI {
#[default] #[default]
None, None,
Solid(SRGBA8), Solid(SRGBA8),
Gradient(GradientUI), Gradient(GradientStops<SRGBA8>),
} }
impl From<&FillChoice> for FillChoiceUI { impl From<&FillChoice> for FillChoiceUI {
@@ -44,7 +44,7 @@ impl From<&FillChoice> for FillChoiceUI {
match value { match value {
FillChoice::None => Self::None, FillChoice::None => Self::None,
FillChoice::Solid(color) => Self::Solid(SRGBA8::from(*color)), 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) 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 }; let Self::Gradient(g) = self else { return None };
Some(g) Some(g)
} }
+35 -1
View File
@@ -8,7 +8,7 @@ use std::sync::atomic::AtomicU64;
use syn::punctuated::Punctuated; use syn::punctuated::Punctuated;
use syn::visit::Visit; use syn::visit::Visit;
use syn::visit_mut::VisitMut; 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; pub(crate) mod classify;
mod entries; mod entries;
@@ -348,6 +348,20 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
}) })
.collect(); .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 let default_types: Vec<_> = regular_fields
.iter() .iter()
.map(|field| match &field.ty { .map(|field| match &field.ty {
@@ -682,6 +696,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
hidden: #input_hidden, hidden: #input_hidden,
exposed: #exposed, exposed: #exposed,
value_source: #value_sources, value_source: #value_sources,
default_colors: #default_colors,
default_type: #default_types, default_type: #default_types,
number_soft_min: #number_soft_min_values, number_soft_min: #number_soft_min_values,
number_soft_max: #number_soft_max_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() ..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()
}
+10
View File
@@ -267,6 +267,16 @@ pub enum ParsedFieldType {
Node(NodeParsedField), 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. /// 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.`). /// Accepts both integer literals (e.g. `1`, `-1`) and float literals (e.g. `1.`, `-500.`).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod brush_stroke;
pub mod migrations { pub mod migrations {
use crate::brush_stroke::BrushStroke; 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> { pub fn migrate_to_brush_strokes<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<BrushStroke>, D::Error> {
use serde::Deserialize; use serde::Deserialize;
+3 -14
View File
@@ -15,7 +15,7 @@ use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector}; use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector};
use raster_types::{CPU, GPU, Raster}; use raster_types::{CPU, GPU, Raster};
use vector_types::gradient::{GradientSpreadMethod, GradientType as GradientTypeValue}; use vector_types::gradient::{GradientSpreadMethod, GradientType as GradientTypeValue};
use vector_types::{Gradient, GradientStop, ReferencePoint}; use vector_types::{Gradient, ReferencePoint};
fn arena_exhausted() -> Interrupt { fn arena_exhausted() -> Interrupt {
GraphError { GraphError {
@@ -555,21 +555,10 @@ pub fn flatten_gradient<'e>(
flatten_leaf_lane(content, ctx.index() as usize) 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. /// 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"))] #[node_macro::node(category("Color"), name("Colors to Gradient"))]
pub fn colors_to_gradient(_: impl Ctx, colors: IList<Color>) -> 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`]. /// 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 RowStep::Continue
}); });
} }
evenly_spaced_gradient(&leaves) Gradient::from(leaves)
} }
pub use _colors_to_gradient_graphic_mod::colors_to_gradient_graphic_entries; pub use _colors_to_gradient_graphic_mod::colors_to_gradient_graphic_entries;
+3 -2
View File
@@ -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.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]); 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]); 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()); 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] #[test]
+32 -5
View File
@@ -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. /// 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"))] #[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 gradient
} }
@@ -1214,16 +1214,43 @@ fn spread_method(_: impl Ctx, gradient: Gradient, spread_method: vector_types::G
(gradient, Attr(spread_method)) (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"))] #[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 // An unwired gradient serves an empty level: no color
if gradient.is_empty() || ctx.index() != 0 { if gradient.is_empty() || ctx.index() != 0 {
return Err(GraphError::past_end().into()); return Err(GraphError::past_end().into());
} }
let position = position.clamp(0., 1.); let spread_method = gradient.lane(0).attr::<SpreadMethodAttr>();
Ok(gradient.element_ref(0).evaluate(position)) 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. /// 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.
+1 -3
View File
@@ -24,9 +24,7 @@ mod adjust_std {
} }
impl Adjust<Color> for Gradient { impl Adjust<Color> for Gradient {
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) { fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
for color in self.color.iter_mut() { *self = self.map_colors(map_fn);
*color = map_fn(color);
}
} }
} }
} }
@@ -38,16 +38,19 @@ mod blend_std {
impl Blend<Color> for Gradient { impl Blend<Color> for Gradient {
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { 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<_>>(); let mut combined_stops = self.positions().into_iter().chain(under.positions()).collect::<Vec<_>>();
combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); 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 stops = combined_stops.into_iter().map(|position| {
let over_color = self.evaluate(position); let over_color = self.evaluate(position, Default::default());
let under_color = under.evaluate(position); let under_color = under.evaluate(position, Default::default());
let color = blend_fn(over_color, under_color); let color = blend_fn(over_color, under_color);
GradientStop { position, midpoint: 0.5, color } GradientStop { position, midpoint: 0.5, color }
}); });
Gradient::new(stops)
let mut gradient = Gradient::new(stops);
gradient.elide_default_attributes();
gradient
} }
} }
} }
+3 -2
View File
@@ -17,18 +17,19 @@ fn gradient_map<T: Adjust<Color> + Clone + Send + Sync + core_types::CacheHash +
Gradient, Gradient,
)] )]
mut image: T, mut image: T,
gradient: IList<Gradient>, #[default(Color::BLACK, Color::WHITE)] gradient: IList<Gradient>,
reverse: bool, reverse: bool,
) -> T { ) -> T {
if gradient.is_empty() { if gradient.is_empty() {
return image; return image;
} }
let spread_method = gradient.lane(0).attr::<vector_types::markers::SpreadMethod>();
let gradient = gradient.element_ref(0); let gradient = gradient.element_ref(0);
image.adjust(|color| { image.adjust(|color| {
let intensity = color.luminance_rec_709(); let intensity = color.luminance_rec_709();
let intensity = if reverse { 1. - intensity } else { intensity }; let intensity = if reverse { 1. - intensity } else { intensity };
gradient.evaluate(intensity as f64) gradient.evaluate(intensity as f64, spread_method)
}); });
image image
+1 -1
View File
@@ -64,7 +64,7 @@ impl Default for Font {
} }
} }
// TODO: Eventually remove this migration document upgrade code // TODO: Eventually remove this document upgrade code
fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> { fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
use serde::Deserialize; use serde::Deserialize;
String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name }) String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name })
+6 -7
View File
@@ -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, _ => 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. /// 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. /// Whether to style the stroke.
stroke: bool, stroke: bool,
/// The range of colors to select from. /// The range of colors to select from.
#[default(Color::BLACK, Color::WHITE)]
#[widget(ParsedWidgetOverride::Custom = "assign_colors_gradient")] #[widget(ParsedWidgetOverride::Custom = "assign_colors_gradient")]
gradient: IList<Gradient>, gradient: IList<Gradient>,
/// Whether to reverse the gradient. /// Whether to reverse the gradient.
@@ -323,7 +324,7 @@ fn fill<'e>(
#[default(Color::BLACK)] #[default(Color::BLACK)]
fill: IList<Graphic<'static>>, fill: IList<Graphic<'static>>,
_backup_color: IList<Color>, _backup_color: IList<Color>,
_backup_gradient: IList<Gradient>, #[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList<Gradient>,
_gradient_type: GradientType, _gradient_type: GradientType,
_spread_method: GradientSpreadMethod, _spread_method: GradientSpreadMethod,
_has_transform: bool, _has_transform: bool,
@@ -344,7 +345,7 @@ fn fill_graphic_leveled<'e>(
(element, _content_fill): (Graphic<'static>, Attr<Fill>), (element, _content_fill): (Graphic<'static>, Attr<Fill>),
#[default(Color::BLACK)] fill: IList<Graphic<'static>>, #[default(Color::BLACK)] fill: IList<Graphic<'static>>,
_backup_color: IList<Color>, _backup_color: IList<Color>,
_backup_gradient: IList<Gradient>, #[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList<Gradient>,
_gradient_type: GradientType, _gradient_type: GradientType,
_spread_method: GradientSpreadMethod, _spread_method: GradientSpreadMethod,
_has_transform: bool, _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)) { 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::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))) => { (Some(Graphic::Color(color_a)), Some(Graphic::Gradient(stops_b))) => {
let mut solid_to_gradient = stops_b.clone(); let solid_to_gradient = stops_b.map_colors(|_| *color_a);
solid_to_gradient.color.iter_mut().for_each(|color| *color = *color_a);
let stops = solid_to_gradient.lerp(stops_b, time); let stops = solid_to_gradient.lerp(stops_b, time);
Some(gradient_paint(b, stops, None)) Some(gradient_paint(b, stops, None))
} }
(Some(Graphic::Gradient(stops_a)), Some(Graphic::Color(color_b))) => { (Some(Graphic::Gradient(stops_a)), Some(Graphic::Color(color_b))) => {
let mut gradient_to_solid = stops_a.clone(); let gradient_to_solid = stops_a.map_colors(|_| *color_b);
gradient_to_solid.color.iter_mut().for_each(|color| *color = *color_b);
let stops = stops_a.lerp(&gradient_to_solid, time); let stops = stops_a.lerp(&gradient_to_solid, time);
Some(gradient_paint(a, stops, None)) Some(gradient_paint(a, stops, None))
} }
+14 -7
View File
@@ -177,13 +177,9 @@ fn write_inputs(page: &mut std::fs::File, valid_input_types: &[Vec<core_types::T
{ {
let default_value = default_value.trim_end_matches('.').trim_end_matches(".0"); // Display whole-number floats as integers let default_value = default_value.trim_end_matches('.').trim_end_matches(".0"); // Display whole-number floats as integers
let render_color = |color| format!(r#"<span style="padding-right: 100px; border: 2px solid var(--color-fog); background: {color}"></span>"#); let default_value = match field.default_colors {
// Compare against the typed default's debug form so the swatch tracks the `Gradient` representation Some(colors) => color_swatch(colors),
let black_to_white_gradient = value::TaggedValue::Gradient(Default::default()).to_debug_string(); None => format!("`{default_value}{}`", field.unit.unwrap_or_default()),
let default_value = match default_value {
"Color::BLACK" => render_color("black"),
gradient if gradient == black_to_white_gradient => render_color("linear-gradient(to right, black, white)"),
_ => format!("`{default_value}{}`", field.unit.unwrap_or_default()),
}; };
details.push(format!("<p>*Default:*&nbsp;{default_value}</p>")); details.push(format!("<p>*Default:*&nbsp;{default_value}</p>"));
@@ -210,6 +206,17 @@ fn write_inputs(page: &mut std::fs::File, valid_input_types: &[Vec<core_types::T
} }
} }
/// A bordered swatch painted with one color, or with several as the stops of a left-to-right gradient.
fn color_swatch(colors: &[core_types::Color]) -> String {
let hex: Vec<String> = colors.iter().map(|&color| format!("#{}", core_types::color::SRGBA8::from(color).to_rgba_hex())).collect();
let background = match hex.as_slice() {
[single] => single.clone(),
multiple => format!("linear-gradient(to right, {})", multiple.join(", ")),
};
format!(r#"<span style="padding-right: 100px; border: 2px solid var(--color-fog); background: {background}"></span>"#)
}
fn write_outputs(page: &mut std::fs::File, valid_primary_outputs: &[core_types::Type]) { fn write_outputs(page: &mut std::fs::File, valid_primary_outputs: &[core_types::Type]) {
// Product // Product
let product = "Result"; let product = "Result";