Add the Curves adjustment node with a Transfer Curve type and editor widget (#4520)

* Add the Curves adjustment node with a Transfer Curve type and editor widget

* Address review feedback on the Transfer Curve widget's edge cases
This commit is contained in:
Keavon Chambers
2026-09-12 12:58:55 -07:00
parent 944d00cac5
commit 3e51b757db
14 changed files with 926 additions and 6 deletions

View File

@@ -274,6 +274,20 @@ impl LayoutMessageHandler {
responses.add(callback_message);
}
Widget::TransferCurveInput(curve_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (curve_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Ok(update) = serde_json::from_value::<TransferCurveInputUpdate>(value) else {
warn!("TransferCurveInput update was not able to be parsed as TransferCurveInputUpdate");
return;
};
(curve_input.on_update.callback)(&update)
}
};
responses.add(callback_message);
}
Widget::IconButton(icon_button) => {
let callback_message = match action {
WidgetValueAction::Commit => (icon_button.on_commit.callback)(&()),
@@ -534,6 +548,23 @@ fn populate_computed_display_fields(layout: &mut Layout) {
Widget::ColorInput(color_input) => {
color_input.chosen_gradient = color_input.value.to_css_background_image();
}
Widget::TransferCurveInput(curve_input) => {
const SAMPLE_COUNT: usize = 128;
let curve = graphene_std::transfer_curve::TransferCurve::new(curve_input.points.iter().map(|&(x, y)| glam::DVec2::new(x, y)).collect());
let evaluator = curve.evaluator();
let [x_min, x_max] = curve_input.domain;
let [y_min, y_max] = curve_input.range;
let (x_span, y_span) = ((x_max - x_min).max(f64::EPSILON), (y_max - y_min).max(f64::EPSILON));
// A spline overshooting the range rides its edge as a flat line, as the clamped adjustment it depicts does
let clamp_to_range = curve_input.clamp_to_range;
curve_input.samples = (0..=SAMPLE_COUNT)
.map(|i| {
let t = i as f64 / SAMPLE_COUNT as f64;
let y = (evaluator.evaluate(x_min + t * x_span) - y_min) / y_span;
(t, if clamp_to_range { y.clamp(0., 1.) } else { y })
})
.collect();
}
Widget::SpectrumInput(spectrum_input) => {
// The track strip spans exactly 0 to 1, which no spread affects, so the widget carries no spread of its own
let settings = graphene_std::vector::style::GradientSettings {

View File

@@ -471,6 +471,7 @@ impl LayoutGroup {
| Widget::ColorComparisonInput(_)
| Widget::ColorPresetsInput(_)
| Widget::SpectrumInput(_)
| Widget::TransferCurveInput(_)
| Widget::VisualColorPickersInput(_) => continue,
};
if val.is_empty() {
@@ -808,6 +809,7 @@ pub enum Widget {
ColorComparisonInput(ColorComparisonInput),
ColorInput(ColorInput),
ColorPresetsInput(ColorPresetsInput),
TransferCurveInput(TransferCurveInput),
DropdownInput(DropdownInput),
IconButton(IconButton),
IconLabel(IconLabel),
@@ -887,6 +889,7 @@ impl DiffUpdate {
| Widget::ColorComparisonInput(_)
| Widget::ColorPresetsInput(_)
| Widget::SpectrumInput(_)
| Widget::TransferCurveInput(_)
| Widget::VisualColorPickersInput(_) => None,
};

View File

@@ -578,6 +578,49 @@ pub enum ColorPresetsInputUpdate {
EyedropperColorCode(String),
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct TransferCurveInput {
// Content
/// The control points in the units of `domain` and `range`, in any x order, since sampling sorts them.
#[widget_builder(constructor)]
pub points: Vec<(f64, f64)>,
/// The x extent the box spans, left to right.
pub domain: [f64; 2],
/// The y extent the box spans, bottom to top.
pub range: [f64; 2],
/// Whether the drawn curve and a dragged point's y stay inside `range`. A point's x always stays inside `domain`.
#[serde(rename = "clampToRange")]
pub clamp_to_range: bool,
/// Polyline of the curve in box-normalized 0..1 coordinates with y upward. Auto-populated from `points` at layout-send time.
#[widget_builder(skip)]
pub samples: Vec<(f64, f64)>,
/// Whether clicking empty space inserts a point.
#[serde(rename = "allowInsert")]
pub allow_insert: bool,
/// Whether double-click or right-click removes a point. The handler still has the final say (e.g., enforcing a minimum count).
#[serde(rename = "allowDelete")]
pub allow_delete: bool,
pub disabled: bool,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<TransferCurveInputUpdate>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum TransferCurveInputUpdate {
MovePoint { index: u32, x: f64, y: f64 },
InsertPoint { x: f64, y: f64 },
DeletePoint { index: u32 },
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]

View File

@@ -16,11 +16,13 @@ use graphene_std::list::{Item, List, NodeIdPath};
use graphene_std::math::float_noise::round_away_float_noise;
use graphene_std::memo::IORecord;
use graphene_std::raster::{
CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice,
AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
SelectiveColorChoice,
};
use graphene_std::raster_types::{CPU, GPU, Raster};
use graphene_std::text::TextAlign;
use graphene_std::text_nodes::StringCapitalization;
use graphene_std::transfer_curve::TransferCurve;
use graphene_std::transform::{ReferencePoint, ScaleType};
use graphene_std::vector::misc::{
ArcType, BezierHandles, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns,
@@ -228,6 +230,7 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
List<GradientInterpolation>,
List<DashPattern>,
List<BoxCorners>,
List<TransferCurve>,
List<StrokeJoin>,
List<StrokeAlign>,
List<StrokeCap>,
@@ -240,6 +243,7 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
List<RedGreenBlueAlpha>,
List<RelativeAbsolute>,
List<SelectiveColorChoice>,
List<AdjustmentChannel>,
List<XY>,
List<ScaleType>,
List<ReferencePoint>,
@@ -283,6 +287,7 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
Item<GradientInterpolation>,
Item<DashPattern>,
Item<BoxCorners>,
Item<TransferCurve>,
Item<StrokeJoin>,
Item<StrokeAlign>,
Item<StrokeCap>,
@@ -295,6 +300,7 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
Item<RedGreenBlueAlpha>,
Item<RelativeAbsolute>,
Item<SelectiveColorChoice>,
Item<AdjustmentChannel>,
Item<XY>,
Item<ScaleType>,
Item<ReferencePoint>,
@@ -562,6 +568,26 @@ impl TableItemLayout for Coverage {
}
}
impl TableItemLayout for TransferCurve {
fn type_name() -> &'static str {
"Transfer Curve"
}
fn identifier(&self) -> String {
let points = self.points().len();
format!("Transfer Curve ({points} {})", if points == 1 { "point" } else { "points" })
}
// The wrapping `Item` already contributes the breadcrumb; the inner list supplies the next level
fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.value_page(data)
}
fn value_widgets(&self, target: PathStep, data: &LayoutData) -> Vec<WidgetInstance> {
self.0.value_widgets(target, data)
}
fn value_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.0.layout_with_breadcrumb(data)
}
}
impl TableItemLayout for BoxCorners {
fn type_name() -> &'static str {
"BoxCorners"
@@ -1044,6 +1070,7 @@ impl_table_item_layout_for_choice_enum!(
RedGreenBlueAlpha,
RelativeAbsolute,
SelectiveColorChoice,
AdjustmentChannel,
XY,
ScaleType,
CentroidType,
@@ -1243,6 +1270,7 @@ macro_rules! known_item_types {
Cover,
DashPattern,
BoxCorners,
TransferCurve,
BlendMode,
GradientForm,
GradientSpread,
@@ -1261,6 +1289,7 @@ macro_rules! known_item_types {
RedGreenBlueAlpha,
RelativeAbsolute,
SelectiveColorChoice,
AdjustmentChannel,
XY,
ScaleType,
ReferencePoint,

View File

@@ -914,6 +914,7 @@ fn static_node_properties() -> NodeProperties {
map.insert("brightness_contrast_properties".to_string(), Box::new(node_properties::brightness_contrast_properties));
map.insert("channel_mixer_properties".to_string(), Box::new(node_properties::channel_mixer_properties));
map.insert("levels_properties".to_string(), Box::new(node_properties::levels_properties));
map.insert("transfer_curves_properties".to_string(), Box::new(node_properties::transfer_curves_properties));
map.insert("hue_saturation_properties".to_string(), Box::new(node_properties::hue_saturation_properties));
map.insert("black_and_white_properties".to_string(), Box::new(node_properties::black_and_white_properties));
map.insert("threshold_properties".to_string(), Box::new(node_properties::threshold_properties));

View File

@@ -20,12 +20,13 @@ use graphene_std::animation::RealTimeMode;
use graphene_std::color::SRGBA8;
use graphene_std::extract_xy::XY;
use graphene_std::raster::{
BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
SelectiveColorChoice,
};
use graphene_std::raster_types::Image;
use graphene_std::text::{Font, TextAlign};
use graphene_std::text_nodes::StringCapitalization;
use graphene_std::transfer_curve::TransferCurve;
use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform};
use graphene_std::vector::misc::BooleanOperation;
use graphene_std::vector::misc::{
@@ -289,6 +290,7 @@ pub(crate) fn property_from_type(
// STRUCT TYPES
// ============
Some(x) if id_is::<Font>(x) => font_widget(default_info),
Some(x) if id_is::<TransferCurve>(x) => transfer_curve_widget(default_info),
Some(x) if id_is::<Footprint>(x) => footprint_widget(default_info, &mut extra_widgets),
Some(x) if id_is::<Box<VectorModification>>(x) => vector_modification_widget(default_info).into(),
Some(x) if id_is::<Image<Color>>(x) => image_data_widget(default_info).into(),
@@ -316,6 +318,7 @@ pub(crate) fn property_from_type(
Some(x) if id_is::<CellularReturnType>(x) => enum_choice::<CellularReturnType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<DomainWarpType>(x) => enum_choice::<DomainWarpType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<RelativeAbsolute>(x) => enum_choice::<RelativeAbsolute>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<AdjustmentChannel>(x) => enum_choice::<AdjustmentChannel>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<GridType>(x) => enum_choice::<GridType>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeCap>(x) => enum_choice::<StrokeCap>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeJoin>(x) => enum_choice::<StrokeJoin>().for_socket(default_info).property_row(),
@@ -1165,6 +1168,44 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button:
LayoutGroup::row(widgets)
}
/// A [`TransferCurve`] input's row: the label, then the curve editor spanning the unit square when the input is not exposed.
pub fn transfer_curve_widget(parameter_widgets_info: ParameterWidgetsInfo) -> LayoutGroup {
let mut widgets = start_widgets(&parameter_widgets_info);
let Some(NodeInput::Value { tagged_value, exposed: false }) = parameter_widgets_info.input() else {
return LayoutGroup::row(widgets);
};
let TaggedValue::TransferCurve(points) = &**tagged_value else { return LayoutGroup::row(widgets) };
let curve = TransferCurve::from(points.clone());
widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
widgets.push(
TransferCurveInput::new(curve.points().iter().map(|point| (point.x, point.y)).collect())
.domain([0., 1.])
.range([0., 1.])
.clamp_to_range(true)
.allow_insert(true)
.allow_delete(true)
.on_update(parameter_widgets_info.update_value(move |update: &TransferCurveInputUpdate| {
let mut curve = curve.clone();
match *update {
TransferCurveInputUpdate::MovePoint { index, x, y } => curve.move_point(index as usize, DVec2::new(x, y)),
TransferCurveInputUpdate::InsertPoint { x, y } => {
curve.insert_point(DVec2::new(x, y));
}
// A transfer curve keeps at least its two end points
TransferCurveInputUpdate::DeletePoint { index } if curve.points().len() > 2 => curve.remove_point(index as usize),
TransferCurveInputUpdate::DeletePoint { .. } => {}
}
TaggedValue::TransferCurve(curve.points().to_vec())
}))
.on_commit(commit_value)
.widget_instance(),
);
LayoutGroup::row(widgets)
}
pub fn font_widget(parameter_widgets_info: ParameterWidgetsInfo) -> LayoutGroup {
let (font_widgets, style_widgets) = font_inputs(parameter_widgets_info);
font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::<Vec<_>>().into()
@@ -1290,6 +1331,29 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node
layout
}
pub(crate) fn transfer_curves_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::curves::*;
let mut channel_info = ParameterWidgetsInfo::new(node_id, ChannelInput, true, context);
channel_info.exposable = false;
let channel = enum_choice::<AdjustmentChannel>().for_socket(channel_info).property_row();
let channel_value = match get_document_node(node_id, context).ok().and_then(|document_node| document_node.input_value(ChannelInput).cloned()) {
Some(TaggedValue::AdjustmentChannel(channel)) => channel,
_ => AdjustmentChannel::Rgb,
};
let curve_parameter: ParameterRef = match channel_value {
AdjustmentChannel::Rgb => CurveInput.into(),
AdjustmentChannel::Red => RedCurveInput.into(),
AdjustmentChannel::Green => GreenCurveInput.into(),
AdjustmentChannel::Blue => BlueCurveInput.into(),
AdjustmentChannel::Alpha => AlphaCurveInput.into(),
};
let transfer_curve = transfer_curve_widget(ParameterWidgetsInfo::new(node_id, curve_parameter, true, context));
vec![channel, transfer_curve]
}
pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::levels::*;