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

@@ -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<f64>),
/// Stored compactly as a `Vec<f64>` of corner values, materializes as an `Item<BoxCorners>` at runtime via `to_dynany`/`to_any`.
BoxCorners(Vec<f64>),
/// Stored compactly as a `Vec<DVec2>` of control points, materializes as an `Item<TransferCurve>` at runtime via `to_dynany`/`to_any`.
TransferCurve(Vec<DVec2>),
/// Stored as the `GradientRamp` exchange struct (nested `{ stops: { color, position?, midpoint? } }`), materializing as an `Item<Gradient>` 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::<Gradient>::from(ramp)),
Self::Strokes(strokes) => {
let list: List<Stroke> = 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::<Gradient>::from(ramp)),
Self::Strokes(strokes) => {
let list: List<Stroke> = 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::<Item<DashPattern>>() => Ok(TaggedValue::DashPattern(downcast::<Item<DashPattern>>(input).unwrap().into_element().0.iter_element_values().copied().collect())),
x if x == TypeId::of::<BoxCorners>() => Ok(TaggedValue::BoxCorners(downcast::<BoxCorners>(input).unwrap().0.iter_element_values().copied().collect())),
x if x == TypeId::of::<Item<BoxCorners>>() => Ok(TaggedValue::BoxCorners(downcast::<Item<BoxCorners>>(input).unwrap().into_element().0.iter_element_values().copied().collect())),
x if x == TypeId::of::<TransferCurve>() => Ok(TaggedValue::TransferCurve(downcast::<TransferCurve>(input).unwrap().points().to_vec())),
x if x == TypeId::of::<Item<TransferCurve>>() => Ok(TaggedValue::TransferCurve(downcast::<Item<TransferCurve>>(input).unwrap().into_element().points().to_vec())),
x if x == TypeId::of::<Gradient>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::<Gradient>(input).unwrap()))),
x if x == TypeId::of::<Item<Gradient>>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::<Item<Gradient>>(input).unwrap()))),
x if x == TypeId::of::<List<Stroke>>() => Ok(TaggedValue::Strokes(downcast::<List<Stroke>>(input).unwrap().into_iter().map(Item::into_element).collect())),
@@ -373,6 +382,8 @@ macro_rules! tagged_value {
x if x == TypeId::of::<Item<DashPattern>>() => Ok(TaggedValue::DashPattern(input.downcast_ref::<Item<DashPattern>>().unwrap().element().0.iter_element_values().copied().collect())),
x if x == TypeId::of::<BoxCorners>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::<BoxCorners>().unwrap().0.iter_element_values().copied().collect())),
x if x == TypeId::of::<Item<BoxCorners>>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::<Item<BoxCorners>>().unwrap().element().0.iter_element_values().copied().collect())),
x if x == TypeId::of::<TransferCurve>() => Ok(TaggedValue::TransferCurve(input.downcast_ref::<TransferCurve>().unwrap().points().to_vec())),
x if x == TypeId::of::<Item<TransferCurve>>() => Ok(TaggedValue::TransferCurve(input.downcast_ref::<Item<TransferCurve>>().unwrap().element().points().to_vec())),
x if x == TypeId::of::<Gradient>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::<Gradient>().unwrap()))),
x if x == TypeId::of::<Item<Gradient>>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::<Item<Gradient>>().unwrap()))),
x if x == TypeId::of::<List<Stroke>>() => Ok(TaggedValue::Strokes(input.downcast_ref::<List<Stroke>>().unwrap().iter_element_values().cloned().collect())),
@@ -403,6 +414,7 @@ macro_rules! tagged_value {
if name == std::any::type_name::<Gradient>() { return Some(TaggedValue::GradientRamp(GradientRamp::default())) }
if name == std::any::type_name::<DashPattern>() { return Some(TaggedValue::DashPattern(Vec::new())) }
if name == std::any::type_name::<BoxCorners>() { return Some(TaggedValue::BoxCorners(Vec::new())) }
if name == std::any::type_name::<TransferCurve>() { 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::<List<Stroke>>() { return Some(TaggedValue::Strokes(Vec::new())) }
if name == std::any::type_name::<BrushCache>() { 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),

View File

@@ -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)]
);
}

View File

@@ -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<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<Gradient>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<DashPattern>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<BoxCorners>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<TransferCurve>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<String>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<f64>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<f32>]),
@@ -126,6 +128,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<Gradient>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<DashPattern>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<BoxCorners>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<TransferCurve>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<String>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<f64>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<f32>]),
@@ -343,6 +346,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
GradientInterpolation,
DashPattern,
BoxCorners,
TransferCurve,
MergeByDistanceAlgorithm,
ExtrudeJoiningAlgorithm,
PointSpacingType,
@@ -352,6 +356,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
RedGreenBlueAlpha,
RelativeAbsolute,
SelectiveColorChoice,
AdjustmentChannel,
Stroke,
XY,
ScaleType,

View File

@@ -12,6 +12,7 @@ pub mod none;
pub mod ops;
pub mod registry;
pub mod render_complexity;
pub mod transfer_curve;
pub mod transform;
pub mod uuid;
pub mod value;

View File

@@ -0,0 +1,222 @@
use crate::list::{Item, List};
use dyn_any::DynAny;
use glam::DVec2;
/// A mapping from an input to output value, drawn as a smooth spline through control points in any x order,
/// which sampling sorts, and held flat beyond the outermost ones. Two points give a straight line and none the identity.
#[derive(Debug, Clone, PartialEq, DynAny, graphene_hash::CacheHash)]
pub struct TransferCurve(pub List<DVec2>);
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<DVec2>) -> 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<Vec<DVec2>> for TransferCurve {
fn from(points: Vec<DVec2>) -> Self {
Self(points.into_iter().map(Item::new_from_element).collect())
}
}
impl From<List<DVec2>> for TransferCurve {
fn from(points: List<DVec2>) -> 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<DVec2>,
second_derivatives: Vec<f64>,
}
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 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).abs() <= f64::EPSILON);
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<f64> {
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());
}
}

View File

@@ -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<T: Adjust<Color>>(
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<T: Adjust<Color>>(
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<T: Adjust<Color> + Send>(
_: impl Ctx,
#[implementations(Raster<CPU>, Color, Gradient)] image: Item<T>,
curve: Item<TransferCurve>,
#[name("(Red) Curve")] red_curve: Item<TransferCurve>,
#[name("(Green) Curve")] green_curve: Item<TransferCurve>,
#[name("(Blue) Curve")] blue_curve: Item<TransferCurve>,
#[name("(Alpha) Curve")] alpha_curve: Item<TransferCurve>,
_channel: Item<AdjustmentChannel>,
) -> Item<T> {
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<DVec2>,
) -> Item<TransferCurve> {
let points: Vec<DVec2> = 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<T: Adjust<Color>>(
#[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
);
}