mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(¶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::<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::*;
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
418
frontend/src/components/widgets/inputs/TransferCurveInput.svelte
Normal file
418
frontend/src/components/widgets/inputs/TransferCurveInput.svelte
Normal file
@@ -0,0 +1,418 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import type { TransferCurveInputUpdate } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const BUTTON_LEFT = 0;
|
||||
const BUTTON_RIGHT = 2;
|
||||
// Smallest horizontal gap kept between neighboring points, in box-normalized units, so the curve stays a function
|
||||
const MINIMUM_X_GAP = 1 / 1024;
|
||||
// How far from a point, in pixels, a press still takes it rather than inserting another
|
||||
const GRAB_RADIUS = 16;
|
||||
|
||||
const dispatch = createEventDispatcher<{ update: TransferCurveInputUpdate; commit: undefined }>();
|
||||
|
||||
export let points: [number, number][];
|
||||
export let samples: [number, number][];
|
||||
export let domain: [number, number];
|
||||
export let range: [number, number];
|
||||
export let clampToRange = true;
|
||||
export let allowInsert = true;
|
||||
export let allowDelete = true;
|
||||
export let disabled = false;
|
||||
|
||||
// Reference to the box DOM element so pointer coordinates can be converted to box-normalized positions
|
||||
let boxElement: HTMLDivElement | undefined = undefined;
|
||||
|
||||
// The point a drag is carrying, held by the frontend for the drag's length; Rust owns the authoritative point data
|
||||
let activePointIndex: number | undefined = undefined;
|
||||
// Where the dragged point began, restored if the drag is cancelled, and whether this drag created it
|
||||
let dragRestore: [number, number] | undefined = undefined;
|
||||
let dragInserted = false;
|
||||
// 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; 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
|
||||
let targetPointIndex: number | undefined = undefined;
|
||||
// The points the last two presses took, which a double-click needs to know landed on the same one
|
||||
let pressIndex: number | undefined = undefined;
|
||||
let previousPressIndex: number | undefined = undefined;
|
||||
|
||||
function emit(update: TransferCurveInputUpdate) {
|
||||
dispatch("update", update);
|
||||
}
|
||||
|
||||
function normalizedX(x: number): number {
|
||||
return (x - domain[0]) / (domain[1] - domain[0] || 1);
|
||||
}
|
||||
|
||||
function normalizedY(y: number): number {
|
||||
return (y - range[0]) / (range[1] - range[0] || 1);
|
||||
}
|
||||
|
||||
function fromNormalized(nx: number, ny: number): [number, number] {
|
||||
return [domain[0] + nx * (domain[1] - domain[0]), range[0] + ny * (range[1] - range[0])];
|
||||
}
|
||||
|
||||
function pointerNormalized(e: MouseEvent): [number, number] | undefined {
|
||||
const rect = boxElement?.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0 || rect.height === 0) return undefined;
|
||||
|
||||
const nx = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
let ny = 1 - (e.clientY - rect.top) / rect.height;
|
||||
if (clampToRange) ny = Math.max(0, Math.min(1, ny));
|
||||
return [nx, ny];
|
||||
}
|
||||
|
||||
// A dragged point passes the others rather than stopping at them, stepping over the hair of space each keeps to stay solvable
|
||||
function clearOfOtherPoints(index: number, nx: number): number {
|
||||
const others = points.filter((_, other) => other !== index).map((point) => normalizedX(point[0]));
|
||||
const isClear = (x: number) => x >= 0 && x <= 1 && others.every((at) => Math.abs(x - at) >= MINIMUM_X_GAP - 1e-12);
|
||||
if (isClear(nx)) return nx;
|
||||
|
||||
// The nearest clear spot sits at the edge of some point's gap, the higher side winning a tie
|
||||
const candidates = others.flatMap((at) => [at + MINIMUM_X_GAP, at - MINIMUM_X_GAP]).filter(isClear);
|
||||
let nearest: number | undefined = undefined;
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i];
|
||||
const distance = Math.abs(candidate - nx);
|
||||
if (nearest === undefined || distance < Math.abs(nearest - nx) || (distance === Math.abs(nearest - nx) && candidate > nearest)) nearest = candidate;
|
||||
}
|
||||
|
||||
return nearest ?? nx;
|
||||
}
|
||||
|
||||
// The point a press takes: the nearest within the grab radius, measured in pixels so points bunched
|
||||
// together resolve by true distance rather than by which of them is drawn on top
|
||||
function nearestPointIndex(position: [number, number]): number | undefined {
|
||||
const rect = boxElement?.getBoundingClientRect();
|
||||
if (!rect) return undefined;
|
||||
|
||||
let nearest: number | undefined = undefined;
|
||||
let nearestDistance = GRAB_RADIUS;
|
||||
|
||||
points.forEach((point, index) => {
|
||||
const x = (normalizedX(point[0]) - position[0]) * rect.width;
|
||||
const y = (normalizedY(point[1]) - position[1]) * rect.height;
|
||||
const distance = Math.sqrt(x * x + y * y);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearest = index;
|
||||
}
|
||||
});
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function beginPointDrag(index: number) {
|
||||
activePointIndex = index;
|
||||
dragRestore = [...points[index]];
|
||||
dragInserted = false;
|
||||
dragMoved = false;
|
||||
dispatch("commit");
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function insertPoint(position: [number, number]) {
|
||||
// Kept clear of the others' x from the start, as a drag would keep it, since the index it will take is the one past the end
|
||||
const point = fromNormalized(clearOfOtherPoints(points.length, position[0]), position[1]);
|
||||
|
||||
dispatch("commit");
|
||||
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, abandoned: false };
|
||||
activePointIndex = undefined;
|
||||
dragRestore = point;
|
||||
dragInserted = true;
|
||||
dragMoved = false;
|
||||
addEvents();
|
||||
}
|
||||
|
||||
function boxPointerDown(e: PointerEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
const position = pointerNormalized(e);
|
||||
if (!position) return;
|
||||
|
||||
// Resolved again here, since a pen or touch press arrives with no hover to have settled it
|
||||
const index = nearestPointIndex(position);
|
||||
targetPointIndex = index;
|
||||
insertPreview = undefined;
|
||||
previousPressIndex = pressIndex;
|
||||
pressIndex = index;
|
||||
|
||||
if (index !== undefined) {
|
||||
if (e.button === BUTTON_LEFT) beginPointDrag(index);
|
||||
else if (e.button === BUTTON_RIGHT) removePoint(index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button === BUTTON_LEFT && allowInsert) insertPoint(insertionAt(position));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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 (!allowDelete || !pressed) return;
|
||||
|
||||
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;
|
||||
|
||||
dispatch("commit");
|
||||
if (end !== undefined) {
|
||||
const corner = fromNormalized(end, end);
|
||||
emit({ MovePoint: { index, x: corner[0], y: corner[1] } });
|
||||
} else {
|
||||
deletePoint(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Takes up the dragging of an inserted point once the reply carries it, found by position since Rust chooses where it lands
|
||||
function adoptInsertedPoint(reported: [number, number][]) {
|
||||
if (!pendingInsert || reported.length <= pendingInsert.priorCount) return;
|
||||
|
||||
const [x, y] = pendingInsert.point;
|
||||
const distanceSquared = (point: [number, number]) => (point[0] - x) ** 2 + (point[1] - y) ** 2;
|
||||
let nearest = 0;
|
||||
for (let i = 1; i < reported.length; i += 1) {
|
||||
if (distanceSquared(reported[i]) < distanceSquared(reported[nearest])) nearest = i;
|
||||
}
|
||||
|
||||
if (pendingInsert.abandoned) deletePoint(nearest);
|
||||
else activePointIndex = nearest;
|
||||
pendingInsert = undefined;
|
||||
}
|
||||
$: adoptInsertedPoint(points);
|
||||
|
||||
// A drag already owns the pointer, so the hover it left behind stands
|
||||
function boxPointerMove(e: PointerEvent) {
|
||||
if (disabled || dragRestore !== undefined) return;
|
||||
|
||||
const position = pointerNormalized(e);
|
||||
targetPointIndex = position ? nearestPointIndex(position) : undefined;
|
||||
insertPreview = allowInsert && targetPointIndex === undefined && position ? insertionAt(position) : undefined;
|
||||
}
|
||||
|
||||
function boxPointerLeave() {
|
||||
targetPointIndex = undefined;
|
||||
insertPreview = undefined;
|
||||
}
|
||||
|
||||
function deletePoint(index: number) {
|
||||
emit({ DeletePoint: { index } });
|
||||
if (activePointIndex === index) activePointIndex = undefined;
|
||||
else if (activePointIndex !== undefined && activePointIndex > index) activePointIndex -= 1;
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (activePointIndex === undefined) return;
|
||||
if (e.buttons === 0) {
|
||||
stopDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const position = pointerNormalized(e);
|
||||
if (!position) return;
|
||||
const point = fromNormalized(clearOfOtherPoints(activePointIndex, position[0]), position[1]);
|
||||
|
||||
dragMoved = true;
|
||||
emit({ MovePoint: { index: activePointIndex, x: point[0], y: point[1] } });
|
||||
}
|
||||
|
||||
function abortDrag() {
|
||||
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();
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
removeEvents();
|
||||
activePointIndex = undefined;
|
||||
dragRestore = undefined;
|
||||
dragInserted = false;
|
||||
if (!pendingInsert?.abandoned) pendingInsert = undefined;
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
stopDrag();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
const BUTTONS_RIGHT = 0b0000_0010;
|
||||
if (e.buttons & BUTTONS_RIGHT) abortDrag();
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
if (boxElement) preventEscapeClosingParentFloatingMenu(boxElement);
|
||||
abortDrag();
|
||||
}
|
||||
|
||||
function addEvents() {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
}
|
||||
|
||||
// The polyline in the box's unit square, with SVG's downward y flipped to point upward
|
||||
$: pathData = samples.map((sample, i) => `${i === 0 ? "M" : "L"}${sample[0]} ${1 - sample[1]}`).join(" ");
|
||||
|
||||
// Where a click would leave a new point: the curve's own height below or above a pointer near it, read off the evenly
|
||||
// spaced samples the editor bakes, or the pointer's own place when it sits further off
|
||||
function insertionAt(position: [number, number]): [number, number] {
|
||||
const height = boxElement?.getBoundingClientRect().height || 0;
|
||||
if (samples.length < 2 || height === 0) return position;
|
||||
|
||||
const along = Math.max(0, Math.min(1, position[0])) * (samples.length - 1);
|
||||
const lower = Math.floor(along);
|
||||
const upper = Math.min(lower + 1, samples.length - 1);
|
||||
const y = samples[lower][1] + (samples[upper][1] - samples[lower][1]) * (along - lower);
|
||||
|
||||
return Math.abs(y - position[1]) * height <= GRAB_RADIUS ? [position[0], y] : position;
|
||||
}
|
||||
|
||||
onDestroy(removeEvents);
|
||||
</script>
|
||||
|
||||
<LayoutCol class="transfer-curve-input" classes={{ disabled }}>
|
||||
<div class="pointer-field" on:pointerdown={boxPointerDown} on:pointermove={boxPointerMove} on:pointerleave={boxPointerLeave} on:dblclick={boxDoubleClick}>
|
||||
<div class="curve-box" bind:this={boxElement}>
|
||||
<svg viewBox="0 0 1 1" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line class="identity-line" x1="0" y1="1" x2="1" y2="0" vector-effect="non-scaling-stroke" />
|
||||
<path class="curve-path" d={pathData} vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
{#each points as point, index}
|
||||
<div
|
||||
class="curve-point"
|
||||
class:active={index === activePointIndex}
|
||||
class:targeted={index === targetPointIndex}
|
||||
style:--point-x={normalizedX(point[0])}
|
||||
style:--point-y={normalizedY(point[1])}
|
||||
></div>
|
||||
{/each}
|
||||
{#if insertPreview}
|
||||
<div class="curve-point preview" style:--point-x={insertPreview[0]} style:--point-y={insertPreview[1]}></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss">
|
||||
.transfer-curve-input {
|
||||
// The grid is inset by the handle radius so a point sitting in a corner stays within the widget's field
|
||||
--handle-radius: 4px;
|
||||
--grid-size: 256px;
|
||||
flex: 1 1 100%;
|
||||
box-sizing: border-box;
|
||||
max-width: calc(var(--grid-size) + 2 * var(--handle-radius));
|
||||
border-radius: 2px;
|
||||
background-color: var(--color-2-mildblack);
|
||||
|
||||
// The inset belongs to the pointer target, so a press in it reaches the points whose handles reach out into it
|
||||
.pointer-field {
|
||||
padding: var(--handle-radius);
|
||||
|
||||
.curve-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: var(--grid-size);
|
||||
aspect-ratio: 1;
|
||||
// Quarter grid lines, with an inset frame supplying the outermost ones on all four edges
|
||||
background-image: linear-gradient(var(--color-3-darkgray) 1px, transparent 1px), linear-gradient(90deg, var(--color-3-darkgray) 1px, transparent 1px);
|
||||
background-size: 25% 25%;
|
||||
box-shadow: inset 0 0 0 1px var(--color-3-darkgray);
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
|
||||
.identity-line {
|
||||
stroke: var(--color-3-darkgray);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.curve-path {
|
||||
fill: none;
|
||||
stroke: var(--color-e-nearwhite);
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.curve-point {
|
||||
position: absolute;
|
||||
left: calc(var(--point-x) * 100%);
|
||||
top: calc((1 - var(--point-y)) * 100%);
|
||||
width: calc(2 * var(--handle-radius));
|
||||
height: calc(2 * var(--handle-radius));
|
||||
margin: calc(-1 * var(--handle-radius));
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
background: var(--color-e-nearwhite);
|
||||
pointer-events: none;
|
||||
|
||||
// A ring around the dot marks the point a click would take
|
||||
&.targeted {
|
||||
z-index: 1;
|
||||
outline: 1px solid var(--color-e-nearwhite);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.preview {
|
||||
background: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
&.active {
|
||||
z-index: 1;
|
||||
background: var(--color-f-white);
|
||||
outline: 1px solid var(--color-f-white);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.pointer-field {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.curve-path {
|
||||
stroke: var(--color-8-uppergray);
|
||||
}
|
||||
|
||||
.curve-point {
|
||||
background: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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),
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
222
node-graph/libraries/core-types/src/transfer_curve.rs
Normal file
222
node-graph/libraries/core-types/src/transfer_curve.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user