From e6ee9b2e989eaf93f38345c096b484d94d41fa51 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 02:51:52 -0700 Subject: [PATCH 1/2] Add the Curves adjustment node with a Transfer Curve type and editor widget --- .../messages/layout/layout_message_handler.rs | 31 ++ .../layout/utility_types/layout_widget.rs | 3 + .../utility_types/widgets/input_widgets.rs | 43 ++ .../data_panel/data_panel_message_handler.rs | 31 +- .../node_graph/document_node_definitions.rs | 1 + .../document/node_graph/node_properties.rs | 66 ++- .../src/components/widgets/WidgetSpan.svelte | 11 + .../widgets/inputs/TransferCurveInput.svelte | 407 ++++++++++++++++++ node-graph/graph-craft/src/document/value.rs | 14 + .../interpreted-executor/src/node_registry.rs | 5 + node-graph/libraries/core-types/src/lib.rs | 1 + .../core-types/src/transfer_curve.rs | 222 ++++++++++ node-graph/nodes/raster/src/adjustments.rs | 84 +++- 13 files changed, 914 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/widgets/inputs/TransferCurveInput.svelte create mode 100644 node-graph/libraries/core-types/src/transfer_curve.rs diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index b8f5623e44d..46d19c402ce 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -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::(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 { diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index 59739951004..e53ab5ca880 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -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, }; diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index cc49fbbaf99..6850cc4788b 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -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, + #[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)] diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index f4775b033ad..7c4d6c1a6d1 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -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, List, List, + List, List, List, List, @@ -240,6 +243,7 @@ fn generate_layout(introspected_data: &Arc, List, List, + List, List, List, List, @@ -283,6 +287,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, + Item, Item, Item, Item, @@ -295,6 +300,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, + Item, Item, Item, Item, @@ -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 { + self.value_page(data) + } + fn value_widgets(&self, target: PathStep, data: &LayoutData) -> Vec { + self.0.value_widgets(target, data) + } + fn value_page(&self, data: &mut LayoutData) -> Vec { + 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, diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index d394330bd5d..ef8bae14b68 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -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)); diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index c8e38735e43..8719ff65785 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -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::(x) => font_widget(default_info), + Some(x) if id_is::(x) => transfer_curve_widget(default_info), Some(x) if id_is::(x) => footprint_widget(default_info, &mut extra_widgets), Some(x) if id_is::>(x) => vector_modification_widget(default_info).into(), Some(x) if id_is::>(x) => image_data_widget(default_info).into(), @@ -316,6 +318,7 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().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(¶meter_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::>().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 { + use graphene_std::raster::curves::*; + + let mut channel_info = ParameterWidgetsInfo::new(node_id, ChannelInput, true, context); + channel_info.exposable = false; + let channel = enum_choice::().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 { use graphene_std::raster::levels::*; diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 1c7023730d4..4bdb35d9300 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -18,6 +18,7 @@ import SpectrumInput from "/src/components/widgets/inputs/SpectrumInput.svelte"; import TextAreaInput from "/src/components/widgets/inputs/TextAreaInput.svelte"; import TextInput from "/src/components/widgets/inputs/TextInput.svelte"; + import TransferCurveInput from "/src/components/widgets/inputs/TransferCurveInput.svelte"; import VisualColorPickersInput from "/src/components/widgets/inputs/VisualColorPickersInput.svelte"; import WorkingColorsInput from "/src/components/widgets/inputs/WorkingColorsInput.svelte"; import IconLabel from "/src/components/widgets/labels/IconLabel.svelte"; @@ -232,6 +233,16 @@ $$events: { value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) }, }), }, + TransferCurveInput: { + component: TransferCurveInput, + getProps: (props, index) => ({ + ...props, + $$events: { + update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false), + commit: () => widgetValueCommit(index, undefined), + }, + }), + }, SpectrumInput: { component: SpectrumInput, getProps: (props, index) => ({ diff --git a/frontend/src/components/widgets/inputs/TransferCurveInput.svelte b/frontend/src/components/widgets/inputs/TransferCurveInput.svelte new file mode 100644 index 00000000000..768fc39f357 --- /dev/null +++ b/frontend/src/components/widgets/inputs/TransferCurveInput.svelte @@ -0,0 +1,407 @@ + + + +
+
+ + + + + {#each points as point, index} +
+ {/each} + {#if insertPreview} +
+ {/if} +
+
+
+ + diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index aec941840e5..bfd3c2607be 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -5,6 +5,7 @@ use crate::proto::{Any as DAny, FutureAny}; use brush_nodes::{BrushCache, Stroke}; use core_types::color::SRGBA8; use core_types::list::{Item, List, NodeIdPath}; +use core_types::transfer_curve::TransferCurve; use core_types::transform::Footprint; use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type, TypeDescriptor}; use dyn_any::DynAny; @@ -89,6 +90,8 @@ macro_rules! tagged_value { DashPattern(Vec), /// Stored compactly as a `Vec` of corner values, materializes as an `Item` at runtime via `to_dynany`/`to_any`. BoxCorners(Vec), + /// Stored compactly as a `Vec` of control points, materializes as an `Item` at runtime via `to_dynany`/`to_any`. + TransferCurve(Vec), /// Stored as the `GradientRamp` exchange struct (nested `{ stops: { color, position?, midpoint? } }`), materializing as an `Item` at runtime. Aliases recover legacy on-disk shapes. /// (Old documents stored flat stops, a tuple list, or the ancient full `Gradient` struct under the legacy `"Gradient"` tag, all routed by `deserialize_tagged_value_with_legacy_migration`.) #[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] @@ -136,6 +139,7 @@ macro_rules! tagged_value { Self::F64Array(values) => values.cache_hash(state), Self::DashPattern(lengths) => lengths.cache_hash(state), Self::BoxCorners(values) => values.cache_hash(state), + Self::TransferCurve(points) => points.cache_hash(state), Self::GradientRamp(ramp) => ramp.cache_hash(state), Self::Strokes(strokes) => strokes.cache_hash(state), Self::BrushCache(cache) => cache.cache_hash(state), @@ -200,6 +204,7 @@ macro_rules! tagged_value { } Self::DashPattern(lengths) => Box::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Box::new(Item::new_from_element(BoxCorners::from(values))), + Self::TransferCurve(points) => Box::new(Item::new_from_element(TransferCurve::from(points))), Self::GradientRamp(ramp) => Box::new(Item::::from(ramp)), Self::Strokes(strokes) => { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); @@ -267,6 +272,7 @@ macro_rules! tagged_value { } Self::DashPattern(lengths) => Arc::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Arc::new(Item::new_from_element(BoxCorners::from(values))), + Self::TransferCurve(points) => Arc::new(Item::new_from_element(TransferCurve::from(points))), Self::GradientRamp(ramp) => Arc::new(Item::::from(ramp)), Self::Strokes(strokes) => { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); @@ -300,6 +306,7 @@ macro_rules! tagged_value { Self::F64Array(_) => list!(f64), Self::DashPattern(_) => item!(DashPattern), Self::BoxCorners(_) => item!(BoxCorners), + Self::TransferCurve(_) => item!(TransferCurve), Self::GradientRamp(_) => item!(Gradient), Self::Strokes(_) => list!(Stroke), Self::BrushCache(_) => item!(BrushCache), @@ -339,6 +346,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::DashPattern(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::BoxCorners(downcast::(input).unwrap().0.iter_element_values().copied().collect())), x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), + x if x == TypeId::of::() => Ok(TaggedValue::TransferCurve(downcast::(input).unwrap().points().to_vec())), + x if x == TypeId::of::>() => Ok(TaggedValue::TransferCurve(downcast::>(input).unwrap().into_element().points().to_vec())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::>(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::Strokes(downcast::>(input).unwrap().into_iter().map(Item::into_element).collect())), @@ -373,6 +382,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::DashPattern(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::BoxCorners(input.downcast_ref::().unwrap().0.iter_element_values().copied().collect())), x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), + x if x == TypeId::of::() => Ok(TaggedValue::TransferCurve(input.downcast_ref::().unwrap().points().to_vec())), + x if x == TypeId::of::>() => Ok(TaggedValue::TransferCurve(input.downcast_ref::>().unwrap().element().points().to_vec())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::>().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::Strokes(input.downcast_ref::>().unwrap().iter_element_values().cloned().collect())), @@ -403,6 +414,7 @@ macro_rules! tagged_value { if name == std::any::type_name::() { return Some(TaggedValue::GradientRamp(GradientRamp::default())) } if name == std::any::type_name::() { return Some(TaggedValue::DashPattern(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BoxCorners(Vec::new())) } + if name == std::any::type_name::() { return Some(TaggedValue::TransferCurve(TransferCurve::default().points().to_vec())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::>() { return Some(TaggedValue::Strokes(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BrushCache(Default::default())) } @@ -460,6 +472,7 @@ macro_rules! tagged_value { Self::F64Array(values) => format!("F64Array({values:?})"), Self::DashPattern(lengths) => format!("DashPattern({lengths:?})"), Self::BoxCorners(values) => format!("BoxCorners({values:?})"), + Self::TransferCurve(points) => format!("TransferCurve({points:?})"), Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"), Self::Strokes(strokes) => format!("Strokes({strokes:?})"), Self::BrushCache(cache) => format!("{cache:?}"), @@ -549,6 +562,7 @@ tagged_value! { DomainWarpType(raster_nodes::adjustments::DomainWarpType), RelativeAbsolute(raster_nodes::adjustments::RelativeAbsolute), SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice), + AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), GridType(vector::misc::GridType), ArcType(vector::misc::ArcType), RowsOrColumns(vector::misc::RowsOrColumns), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index f9df0dceb40..a298900c301 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -20,6 +20,7 @@ use graphene_std::raster::{CPU, Raster}; use graphene_std::render_node::RenderIntermediate; 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}; use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, @@ -54,6 +55,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -126,6 +128,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), @@ -343,6 +346,7 @@ fn node_registry() -> HashMap HashMap); + +impl Default for TransferCurve { + /// The straight line from (0, 0) to (1, 1). + fn default() -> Self { + Self::new(vec![DVec2::ZERO, DVec2::ONE]) + } +} + +impl TransferCurve { + /// Builds a curve from points in any order. + pub fn new(mut points: Vec) -> Self { + points.sort_by(|a, b| a.x.total_cmp(&b.x)); + Self::from(points) + } + + /// The control points in the order they are stored, which a drag may carry out of x order. + pub fn points(&self) -> &[DVec2] { + self.0.iter_element_values().as_slice() + } + + /// Whether every control point sits on the y=x diagonal, so the curve leaves the values between them unchanged. + pub fn is_identity(&self) -> bool { + self.points().iter().all(|point| point.x == point.y) + } + + /// Adds a point ahead of the first one to its right, and returns its index. + pub fn insert_point(&mut self, point: DVec2) -> usize { + let index = self.points().iter().position(|existing| existing.x > point.x).unwrap_or(self.0.len()); + + // The list has no insert of its own, so the points are laid out fresh around the new one + let mut points = self.points().to_vec(); + points.insert(index, point); + self.0 = points.into_iter().map(Item::new_from_element).collect(); + + index + } + + pub fn remove_point(&mut self, index: usize) { + if index >= self.0.len() { + return; + } + + let mut points = self.points().to_vec(); + points.remove(index); + self.0 = points.into_iter().map(Item::new_from_element).collect(); + } + + /// Moves a point, which may carry it past others into a new place along the curve while it keeps its index. + pub fn move_point(&mut self, index: usize, point: DVec2) { + let Some(existing) = self.0.element_mut(index) else { return }; + *existing = point; + } + + /// Prepares the curve for repeated sampling: the spline through the points is solved once here rather than + /// on every [`TransferCurveEvaluator::evaluate`] call. + pub fn evaluator(&self) -> TransferCurveEvaluator { + TransferCurveEvaluator::new(self.points()) + } + + /// Samples the curve at `x`. Looping over many values should be done by holding a [`TransferCurve::evaluator`] instead. + pub fn evaluate(&self, x: f64) -> f64 { + self.evaluator().evaluate(x) + } +} + +impl From> for TransferCurve { + fn from(points: Vec) -> Self { + Self(points.into_iter().map(Item::new_from_element).collect()) + } +} + +impl From> for TransferCurve { + fn from(points: List) -> Self { + Self(points) + } +} + +/// A curve prepared for repeated sampling by [`TransferCurve::evaluator`]: +/// a natural cubic spline through the points, whose second derivative vanishes at both ends. +#[derive(Debug, Clone)] +pub struct TransferCurveEvaluator { + points: Vec, + second_derivatives: Vec, +} + +impl TransferCurveEvaluator { + fn new(points: &[DVec2]) -> Self { + let mut points = points.to_vec(); + points.sort_by(|a, b| a.x.total_cmp(&b.x)); + + // Points sharing an x would make the spline's system singular, so the later-stored one at each x stands alone + points.reverse(); + points.dedup_by(|a, b| a.x == b.x); + points.reverse(); + + let second_derivatives = natural_spline_second_derivatives(&points); + Self { points, second_derivatives } + } + + /// Samples the curve at `x`, holding the outermost points' values beyond them. + pub fn evaluate(&self, x: f64) -> f64 { + let points = &self.points; + match points.len() { + 0 => return x, + 1 => return points[0].y, + _ => {} + } + if x <= points[0].x { + return points[0].y; + } + if x >= points[points.len() - 1].x { + return points[points.len() - 1].y; + } + + // O(log n) search for the segment holding x + let upper = points.partition_point(|point| point.x <= x).min(points.len() - 1); + let lower = upper - 1; + let (a, b) = (points[lower], points[upper]); + let width = (b.x - a.x).max(f64::EPSILON); + + // The cubic segment from its two end second derivatives + let t_b = (x - a.x) / width; + let t_a = 1. - t_b; + let (m_a, m_b) = (self.second_derivatives[lower], self.second_derivatives[upper]); + t_a * a.y + t_b * b.y + ((t_a * t_a * t_a - t_a) * m_a + (t_b * t_b * t_b - t_b) * m_b) * width * width / 6. + } +} + +/// Second derivatives of the natural cubic spline through sorted `points`, solved by the tridiagonal (Thomas) algorithm in O(n). +fn natural_spline_second_derivatives(points: &[DVec2]) -> Vec { + let n = points.len(); + let mut second_derivatives = vec![0.; n]; + if n < 3 { + return second_derivatives; + } + + let width = |i: usize| (points[i + 1].x - points[i].x).max(f64::EPSILON); + let slope = |i: usize| (points[i + 1].y - points[i].y) / width(i); + + // Forward sweep over the interior rows, whose diagonal is 2(h[i-1] + h[i]) with off-diagonals h[i-1] and h[i] + let mut scratch = vec![0.; n]; + for i in 1..n - 1 { + let (h_previous, h_next) = (width(i - 1), width(i)); + let denominator = 2. * (h_previous + h_next) - h_previous * scratch[i - 1]; + scratch[i] = h_next / denominator; + second_derivatives[i] = (6. * (slope(i) - slope(i - 1)) - h_previous * second_derivatives[i - 1]) / denominator; + } + + // Back substitution, with the natural end conditions leaving both ends at zero + for i in (1..n - 1).rev() { + second_derivatives[i] -= scratch[i] * second_derivatives[i + 1]; + } + + second_derivatives +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_and_lines() { + let identity = TransferCurve::default(); + assert!(identity.is_identity()); + assert!((identity.evaluate(0.3) - 0.3).abs() < 1e-12); + + let line = TransferCurve::new(vec![DVec2::new(1., 0.), DVec2::new(0., 1.)]); + assert!((line.evaluate(0.25) - 0.75).abs() < 1e-12); + assert_eq!(line.evaluate(-1.), 1.); + assert_eq!(line.evaluate(2.), 0.); + } + + #[test] + fn spline_passes_through_points_and_stays_smooth() { + let curve = TransferCurve::new(vec![DVec2::ZERO, DVec2::new(0.25, 0.5), DVec2::new(0.75, 0.6), DVec2::ONE]); + let evaluator = curve.evaluator(); + for point in curve.points() { + assert!((evaluator.evaluate(point.x) - point.y).abs() < 1e-12); + } + + // The first derivative is continuous across the interior points + let step = 1e-6; + for point in &curve.points()[1..3] { + let before = (evaluator.evaluate(point.x) - evaluator.evaluate(point.x - step)) / step; + let after = (evaluator.evaluate(point.x + step) - evaluator.evaluate(point.x)) / step; + assert!((before - after).abs() < 1e-3, "kink at {}: {before} vs {after}", point.x); + } + } + + #[test] + fn points_sharing_an_x_leave_the_later_one_standing() { + let curve = TransferCurve::from(vec![DVec2::ZERO, DVec2::new(0.5, 0.2), DVec2::new(0.5, 0.8), DVec2::ONE]); + assert!((curve.evaluate(0.5) - 0.8).abs() < 1e-12); + + // A singular system would send the neighboring segments off to enormous values + for x in [0.1, 0.25, 0.4, 0.6, 0.75, 0.9] { + assert!(curve.evaluate(x).abs() < 2., "runaway value {} at {x}", curve.evaluate(x)); + } + } + + #[test] + fn a_moved_point_may_pass_another_while_keeping_its_index() { + let mut curve = TransferCurve::default(); + assert_eq!(curve.insert_point(DVec2::new(0.5, 0.7)), 1); + + // Carried past the point that was to its right, it stays at its own index and sampling sorts it into its new place + curve.move_point(1, DVec2::new(1.5, 0.2)); + assert_eq!(curve.points()[1], DVec2::new(1.5, 0.2)); + assert_eq!(curve.evaluate(2.), 0.2); + + curve.remove_point(1); + assert!(curve.is_identity()); + } +} diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index e86cf04fe9e..d7833c5ca4d 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -4,7 +4,11 @@ use crate::adjust::Adjust; use crate::cubic_spline::CubicSplines; use core::fmt::Debug; #[cfg(feature = "std")] -use core_types::list::Item; +use core_types::list::{Item, List}; +#[cfg(feature = "std")] +use core_types::transfer_curve::{TransferCurve, TransferCurveEvaluator}; +#[cfg(feature = "std")] +use glam::DVec2; use glam::Vec3; use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear}; use no_std_types::context::Ctx; @@ -263,6 +267,23 @@ fn brightness_contrast>( input } +#[repr(u32)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[widget(Dropdown)] +/// The channel whose settings are shown, with RGB adjusting all three color channels together. +pub enum AdjustmentChannel { + #[default] + #[label("RGB")] + Rgb, + Red, + Green, + Blue, + Alpha, +} + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels // @@ -349,6 +370,59 @@ fn levels>( image } +// Aims for interoperable compatibility with: +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27curv%27%20%3D%20Curves +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Curves%20file%20format +// +// Each curve is any number of (x, y) points on 0..1 joined by a natural cubic spline held flat beyond the outermost +// points, and the per-channel curves apply before the composite one, like Levels. The value between those two stages +// stays exact rather than rounding through an 8-bit table, which can leave results a level away from 8-bit pipelines. +// Needs the heap for its curves, so it stays off the shader build for now. +#[cfg(feature = "std")] +#[node_macro::node(category("Raster: Adjustment"), properties("transfer_curves_properties"))] +async fn curves + Send>( + _: impl Ctx, + #[implementations(Raster, Color, Gradient)] image: Item, + curve: Item, + #[name("(Red) Curve")] red_curve: Item, + #[name("(Green) Curve")] green_curve: Item, + #[name("(Blue) Curve")] blue_curve: Item, + #[name("(Alpha) Curve")] alpha_curve: Item, + _channel: Item, +) -> Item { + let mut image = image; + let composite = curve.into_element().evaluator(); + let red = red_curve.into_element().evaluator(); + let green = green_curve.into_element().evaluator(); + let blue = blue_curve.into_element().evaluator(); + let alpha = alpha_curve.into_element().evaluator(); + let map = |channel: &TransferCurveEvaluator, value: f32| composite.evaluate(channel.evaluate(value as f64).clamp(0., 1.)).clamp(0., 1.) as f32; + + image.element_mut().adjust(|color| { + // Curves math operates in gamma space + let [r, g, b, a] = color.to_gamma_srgb_channels(); + + // Alpha stands apart from the composite curve that the three color channels pass through + let a = alpha.evaluate(a as f64).clamp(0., 1.) as f32; + + Color::from_gamma_srgb_channels(map(&red, r), map(&green, g), map(&blue, b), a) + }); + + image +} + +/// Builds a transfer curve from a `Vec2[]` of control points, each mapping the input value at its x to the output value at its y. A smooth spline runs through them, holding the outermost points' values beyond them. +#[cfg(feature = "std")] +#[node_macro::node(category("Raster: Adjustment"), name("Points to Transfer Curve"))] +fn points_to_transfer_curve( + _: impl Ctx, + /// The control points, in any order, with both coordinates on the 0 to 1 range. + points: List, +) -> Item { + let points: Vec = points.iter_element_values().copied().collect(); + Item::new_from_element(TransferCurve::new(points)) +} + // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blwh%27%20%3D%20Black%20and%20White // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Black%20White%20(Photoshop%20CS3) @@ -1124,7 +1198,10 @@ fn exposure>( #[cfg(feature = "std")] mod _graphene_hash_impls { - use super::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice}; + use super::{ + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + SelectiveColorChoice, + }; graphene_hash::impl_via_hash!( LuminanceCalculation, RedGreenBlue, @@ -1135,7 +1212,8 @@ mod _graphene_hash_impls { CellularReturnType, DomainWarpType, RelativeAbsolute, - SelectiveColorChoice + SelectiveColorChoice, + AdjustmentChannel ); } From 96f5644a867d31fb55d487ea3706c6ed358778b5 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 12 Sep 2026 12:36:34 -0700 Subject: [PATCH 2/2] Address review feedback on the Transfer Curve widget's edge cases --- .../widgets/inputs/TransferCurveInput.svelte | 36 ++++++++++++------- node-graph/graph-craft/src/proto.rs | 2 +- .../core-types/src/transfer_curve.rs | 4 +-- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/widgets/inputs/TransferCurveInput.svelte b/frontend/src/components/widgets/inputs/TransferCurveInput.svelte index 768fc39f357..5897ea2762c 100644 --- a/frontend/src/components/widgets/inputs/TransferCurveInput.svelte +++ b/frontend/src/components/widgets/inputs/TransferCurveInput.svelte @@ -33,7 +33,7 @@ // Whether the press moved the point, so the double-click a second press can produce deletes nothing let dragMoved = false; // An insert waiting to be reported back, with the point asked for and the count before it, so its index can be read off the reply - let pendingInsert: { point: [number, number]; priorCount: number } | undefined = undefined; + let pendingInsert: { point: [number, number]; priorCount: number; abandoned: boolean } | undefined = undefined; // The curve's place under the pointer, previewed while the pointer sits over empty space let insertPreview: [number, number] | undefined = undefined; // The point a press would take, lit so it is clear which one a click affects @@ -76,7 +76,11 @@ if (other === index) return; const at = normalizedX(point[0]); - if (Math.abs(x - at) < MINIMUM_X_GAP) x = at + (x >= at ? MINIMUM_X_GAP : -MINIMUM_X_GAP); + if (Math.abs(x - at) >= MINIMUM_X_GAP) return; + + // Steps to the side it came from, or the other side when that one would leave the box + const ahead = x >= at ? at + MINIMUM_X_GAP : at - MINIMUM_X_GAP; + x = ahead >= 0 && ahead <= 1 ? ahead : 2 * at - ahead; }); return x; @@ -121,7 +125,7 @@ emit({ InsertPoint: { x: point[0], y: point[1] } }); // The drag waits for the reply, since until then an index would name a neighbor in the curve as it stands without the point - pendingInsert = { point, priorCount: points.length }; + pendingInsert = { point, priorCount: points.length, abandoned: false }; activePointIndex = undefined; dragRestore = point; dragInserted = true; @@ -144,7 +148,7 @@ if (index !== undefined) { if (e.button === BUTTON_LEFT) beginPointDrag(index); - else if (e.button === BUTTON_RIGHT && allowDelete) deletePoint(index); + else if (e.button === BUTTON_RIGHT) removePoint(index); return; } @@ -154,22 +158,26 @@ // Acts only where both presses took the same point, so the one an empty-space click inserts is not deleted by the click after it function boxDoubleClick() { if (disabled || dragMoved || pressIndex === undefined || pressIndex !== previousPressIndex) return; + removePoint(pressIndex); + } - const pressed = points[pressIndex]; + // A right-click or double-click removes a point, except that the outermost points anchor the corners and return to their own instead + function removePoint(index: number) { + const pressed = points[index]; if (!pressed) return; - // The outermost points anchor the corners instead of being removable, so one of those returns to its own let end: number | undefined = undefined; if (points.every((point) => point[0] >= pressed[0])) end = 0; else if (points.every((point) => point[0] <= pressed[0])) end = 1; if (end !== undefined) { const corner = fromNormalized(end, end); - emit({ MovePoint: { index: pressIndex, x: corner[0], y: corner[1] } }); - return; + dispatch("commit"); + emit({ MovePoint: { index, x: corner[0], y: corner[1] } }); + } else if (allowDelete) { + dispatch("commit"); + deletePoint(index); } - - if (allowDelete) deletePoint(pressIndex); } // Takes up the dragging of an inserted point once the reply carries it, found by position since Rust chooses where it lands @@ -183,7 +191,8 @@ if (distanceSquared(reported[i]) < distanceSquared(reported[nearest])) nearest = i; } - activePointIndex = nearest; + if (pendingInsert.abandoned) deletePoint(nearest); + else activePointIndex = nearest; pendingInsert = undefined; } $: adoptInsertedPoint(points); @@ -227,6 +236,9 @@ if (activePointIndex !== undefined) { if (dragInserted) deletePoint(activePointIndex); else if (dragRestore) emit({ MovePoint: { index: activePointIndex, x: dragRestore[0], y: dragRestore[1] } }); + } else if (pendingInsert) { + // The reply has yet to name the inserted point, so it is deleted when that arrives + pendingInsert.abandoned = true; } stopDrag(); } @@ -236,7 +248,7 @@ activePointIndex = undefined; dragRestore = undefined; dragInserted = false; - pendingInsert = undefined; + if (!pendingInsert?.abandoned) pendingInsert = undefined; } function onPointerUp() { diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index db58d1b779d..ecfe71fea53 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -1059,7 +1059,7 @@ mod test { // If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them. assert_eq!( ids, - vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)] + vec![NodeId(9617677014563055585), NodeId(3306304180790283913), NodeId(4482673701109291121), NodeId(1535890178157254933)] ); } diff --git a/node-graph/libraries/core-types/src/transfer_curve.rs b/node-graph/libraries/core-types/src/transfer_curve.rs index edf27a3dac8..6febae3b877 100644 --- a/node-graph/libraries/core-types/src/transfer_curve.rs +++ b/node-graph/libraries/core-types/src/transfer_curve.rs @@ -96,9 +96,9 @@ impl TransferCurveEvaluator { let mut points = points.to_vec(); points.sort_by(|a, b| a.x.total_cmp(&b.x)); - // Points sharing an x would make the spline's system singular, so the later-stored one at each x stands alone + // Points within epsilon of the same x would make the spline's system singular, so the later-stored one stands alone points.reverse(); - points.dedup_by(|a, b| a.x == b.x); + points.dedup_by(|a, b| (a.x - b.x).abs() <= f64::EPSILON); points.reverse(); let second_derivatives = natural_spline_second_derivatives(&points);