diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 8b3d1fc2..f32f3bc4 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -123,6 +123,12 @@ impl Vec> MessageHandler { + let curve = serde_json::from_value(value).expect("CurveInput event data could not be deserialized"); + curve_input.value = curve; + let callback_message = (curve_input.on_update.callback)(curve_input); + responses.add(callback_message); + } Widget::DropdownInput(dropdown_input) => { let update_value = value.as_u64().expect("DropdownInput update was not of type: u64"); dropdown_input.selected_index = Some(update_value as u32); diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index 2045429d..762d94a3 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -281,6 +281,7 @@ impl LayoutGroup { let val = match &mut widget.widget { Widget::CheckboxInput(x) => &mut x.tooltip, Widget::ColorInput(x) => &mut x.tooltip, + Widget::CurveInput(x) => &mut x.tooltip, Widget::DropdownInput(x) => &mut x.tooltip, Widget::FontInput(x) => &mut x.tooltip, Widget::IconButton(x) => &mut x.tooltip, @@ -430,6 +431,7 @@ pub enum Widget { BreadcrumbTrailButtons(BreadcrumbTrailButtons), CheckboxInput(CheckboxInput), ColorInput(ColorInput), + CurveInput(CurveInput), DropdownInput(DropdownInput), FontInput(FontInput), IconButton(IconButton), @@ -512,6 +514,7 @@ impl DiffUpdate { Widget::PopoverButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), Widget::TextButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), Widget::IconLabel(_) + | Widget::CurveInput(_) | Widget::InvisibleStandinInput(_) | Widget::PivotAssist(_) | Widget::RadioInput(_) 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 6e19704d..5e4b8ecf 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -4,6 +4,7 @@ use crate::messages::layout::utility_types::widget_prelude::*; use document_legacy::layers::layer_info::LayerDataTypeDiscriminant; use document_legacy::LayerId; use graphene_core::raster::color::Color; +use graphene_core::raster::curve::Curve; use graphite_proc_macros::WidgetBuilder; use derivative::*; @@ -414,3 +415,19 @@ pub struct TextInput { #[derivative(Debug = "ignore", PartialEq = "ignore")] pub on_update: WidgetCallback, } + +#[derive(Clone, Serialize, Deserialize, Derivative, WidgetBuilder, specta::Type)] +#[derivative(Debug, PartialEq, Default)] +pub struct CurveInput { + #[widget_builder(constructor)] + pub value: Curve, + + pub disabled: bool, + + pub tooltip: String, + + // Callbacks + #[serde(skip)] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + pub on_update: WidgetCallback, +} diff --git a/editor/src/messages/portfolio/document/node_graph/graph_operation_message_handler/transform_utils.rs b/editor/src/messages/portfolio/document/node_graph/graph_operation_message_handler/transform_utils.rs index 8afb21b4..00bfffd7 100644 --- a/editor/src/messages/portfolio/document/node_graph/graph_operation_message_handler/transform_utils.rs +++ b/editor/src/messages/portfolio/document/node_graph/graph_operation_message_handler/transform_utils.rs @@ -1,11 +1,13 @@ use crate::messages::portfolio::document::node_graph::VectorDataModification; + use bezier_rs::{ManipulatorGroup, Subpath}; use document_legacy::document::Document; -use glam::{DAffine2, DVec2}; use graph_craft::document::{value::TaggedValue, NodeInput}; use graphene_core::uuid::ManipulatorGroupId; use graphene_core::vector::{ManipulatorPointId, SelectedType}; +use glam::{DAffine2, DVec2}; + /// Convert an affine transform into the tuple `(scale, angle, translation, shear)` assuming `shear.y = 0`. pub fn compute_scale_angle_translation_shear(transform: DAffine2) -> (DVec2, f64, DVec2, DVec2) { let x_axis = transform.matrix2.x_axis; diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/document_node_types.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/document_node_types.rs index 8b0a340e..3810d201 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/document_node_types.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/document_node_types.rs @@ -1524,6 +1524,18 @@ fn static_nodes() -> Vec { properties: node_properties::brightness_contrast_properties, ..Default::default() }, + DocumentNodeType { + name: "Curves", + category: "Image Adjustments", + identifier: NodeImplementation::proto("graphene_core::raster::CurvesNode<_>"), + inputs: vec![ + DocumentInputType::value("Image", TaggedValue::ImageFrame(ImageFrame::empty()), true), + DocumentInputType::value("Curve", TaggedValue::Curve(Default::default()), false), + ], + outputs: vec![DocumentOutputType::new("Image", FrontendGraphDataType::Raster)], + properties: node_properties::curves_properties, + ..Default::default() + }, DocumentNodeType { name: "Threshold", category: "Image Adjustments", diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/node_properties.rs index 662acfdd..bdd19a0f 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler/node_properties.rs @@ -589,6 +589,25 @@ fn color_widget(document_node: &DocumentNode, node_id: u64, index: usize, name: } LayoutGroup::Row { widgets } } + +fn curves_widget(document_node: &DocumentNode, node_id: u64, index: usize, name: &str, blank_assist: bool) -> LayoutGroup { + let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist); + + if let NodeInput::Value { + tagged_value: TaggedValue::Curve(curve), + exposed: false, + } = &document_node.inputs[index] + { + widgets.extend_from_slice(&[ + Separator::new(SeparatorType::Unrelated).widget_holder(), + CurveInput::new(curve.clone()) + .on_update(update_value(|x: &CurveInput| TaggedValue::Curve(x.value.clone()), node_id, index)) + .widget_holder(), + ]) + } + LayoutGroup::Row { widgets } +} + /// Properties for the input node, with information describing how frames work and a refresh button pub fn input_properties(_document_node: &DocumentNode, _node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { let information = TextLabel::new("The graph's input frame is the rasterized artwork under the layer").widget_holder(); @@ -777,6 +796,12 @@ pub fn brightness_contrast_properties(document_node: &DocumentNode, node_id: Nod ] } +pub fn curves_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { + let curves = curves_widget(document_node, node_id, 1, "Curve", true); + + vec![curves] +} + pub fn _blur_image_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { let radius = number_widget(document_node, node_id, 1, "Radius", NumberInput::default().min(0.).max(20.).int(), true); let sigma = number_widget(document_node, node_id, 2, "Sigma", NumberInput::default().min(0.).max(10000.), true); diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index a9f56a4e..4eb8342b 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -3,11 +3,11 @@ use crate::messages::portfolio::document::node_graph::VectorDataModification; use crate::messages::prelude::*; use bezier_rs::{Bezier, TValue}; +use document_legacy::document::Document; use document_legacy::LayerId; use graphene_core::uuid::ManipulatorGroupId; use graphene_core::vector::{ManipulatorPointId, SelectedType, VectorData}; -use document_legacy::document::Document; use glam::DVec2; #[derive(Clone, Debug, Default)] diff --git a/frontend/src/components/widgets/WidgetRow.svelte b/frontend/src/components/widgets/WidgetRow.svelte index e6fb0b23..7590580d 100644 --- a/frontend/src/components/widgets/WidgetRow.svelte +++ b/frontend/src/components/widgets/WidgetRow.svelte @@ -13,6 +13,7 @@ import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte"; import CheckboxInput from "@graphite/components/widgets/inputs/CheckboxInput.svelte"; import ColorInput from "@graphite/components/widgets/inputs/ColorInput.svelte"; + import CurveInput from "@graphite/components/widgets/inputs/CurveInput.svelte"; import DropdownInput from "@graphite/components/widgets/inputs/DropdownInput.svelte"; import FontInput from "@graphite/components/widgets/inputs/FontInput.svelte"; import LayerReferenceInput from "@graphite/components/widgets/inputs/LayerReferenceInput.svelte"; @@ -95,6 +96,10 @@ {#if colorInput} updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} /> {/if} + {@const curvesInput = narrowWidgetProps(component.props, "CurveInput")} + {#if curvesInput} + debouncer((value) => updateLayout(index, value), { debounceTime: 120 }).updateValue(detail)} /> + {/if} {@const dropdownInput = narrowWidgetProps(component.props, "DropdownInput")} {#if dropdownInput} updateLayout(index, detail)} sharpRightCorners={nextIsSuffix} /> diff --git a/frontend/src/components/widgets/inputs/CurveInput.svelte b/frontend/src/components/widgets/inputs/CurveInput.svelte new file mode 100644 index 00000000..8d3dbeb7 --- /dev/null +++ b/frontend/src/components/widgets/inputs/CurveInput.svelte @@ -0,0 +1,261 @@ + + + + + {#each { length: GRID_SIZE - 1 } as _, i} + + + {/each} + + {#if selectedNodeIndex !== undefined} + {@const group = groups[selectedNodeIndex]} + {#each [0, 1] as i} + + handleManipulatorPointerDown(e, -i - 1)} /> + {/each} + {/if} + {#each groups as group, i} + handleManipulatorPointerDown(e, i)} /> + {/each} + + + + + diff --git a/frontend/src/utility-functions/debounce.ts b/frontend/src/utility-functions/debounce.ts index 8f01a6be..4c53968d 100644 --- a/frontend/src/utility-functions/debounce.ts +++ b/frontend/src/utility-functions/debounce.ts @@ -7,23 +7,23 @@ export type DebouncerOptions = { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function debouncer(callFn: (value: T) => unknown, { debounceTime = 60 }: Partial = {}) { let currentValue: T | undefined; + let recentlyUpdated: boolean = false; const emitValue = (): void => { - if (currentValue === undefined) { - throw new Error("Tried to emit undefined value from debouncer. This should never be possible"); - } - const emittingValue = currentValue; - currentValue = undefined; - callFn(emittingValue); + recentlyUpdated = false; + if (currentValue === undefined) return; + updateValue(currentValue); }; const updateValue = (newValue: T): void => { - if (currentValue !== undefined) { + if (recentlyUpdated) { currentValue = newValue; return; } - currentValue = newValue; + callFn(newValue); + recentlyUpdated = true; + currentValue = undefined; setTimeout(emitValue, debounceTime); }; diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts index 3ea2382b..d4b846b5 100644 --- a/frontend/src/wasm-communication/messages.ts +++ b/frontend/src/wasm-communication/messages.ts @@ -800,6 +800,26 @@ export type MenuListEntry = MenuEntryCommon & { ref?: any; }; +export class CurveManipulatorGroup { + anchor!: [number, number]; + handles!: [[number, number], [number, number]]; +} + +export class Curve { + manipulatorGroups!: CurveManipulatorGroup[]; + firstHandle!: [number, number]; + lastHandle!: [number, number]; +} + +export class CurveInput extends WidgetProps { + value!: Curve; + + disabled!: boolean; + + @Transform(({ value }: { value: string }) => value || undefined) + tooltip!: string | undefined; +} + export class DropdownInput extends WidgetProps { entries!: MenuListEntry[][]; @@ -1091,6 +1111,7 @@ const widgetSubTypes = [ { value: BreadcrumbTrailButtons, name: "BreadcrumbTrailButtons" }, { value: CheckboxInput, name: "CheckboxInput" }, { value: ColorInput, name: "ColorInput" }, + { value: CurveInput, name: "CurveInput" }, { value: DropdownInput, name: "DropdownInput" }, { value: FontInput, name: "FontInput" }, { value: IconButton, name: "IconButton" }, diff --git a/libraries/bezier-rs/src/bezier/core.rs b/libraries/bezier-rs/src/bezier/core.rs index 9cf37104..980b9a24 100644 --- a/libraries/bezier-rs/src/bezier/core.rs +++ b/libraries/bezier-rs/src/bezier/core.rs @@ -4,7 +4,7 @@ use std::fmt::Write; /// Functionality relating to core `Bezier` operations, such as constructors and `abs_diff_eq`. impl Bezier { // TODO: Consider removing this function - /// Create a quadratic bezier using the provided coordinates as the start, handle, and end points. + /// Create a linear bezier using the provided coordinates as the start and end points. pub fn from_linear_coordinates(x1: f64, y1: f64, x2: f64, y2: f64) -> Self { Bezier { start: DVec2::new(x1, y1), @@ -13,7 +13,7 @@ impl Bezier { } } - /// Create a quadratic bezier using the provided DVec2s as the start, handle, and end points. + /// Create a linear bezier using the provided DVec2s as the start and end points. /// pub fn from_linear_dvec2(p1: DVec2, p2: DVec2) -> Self { Bezier { diff --git a/libraries/bezier-rs/src/bezier/solvers.rs b/libraries/bezier-rs/src/bezier/solvers.rs index f8dcaa0d..326d24a0 100644 --- a/libraries/bezier-rs/src/bezier/solvers.rs +++ b/libraries/bezier-rs/src/bezier/solvers.rs @@ -164,6 +164,40 @@ impl Bezier { min_corner.x <= bounding_box_min.x && min_corner.y <= bounding_box_min.y && bounding_box_max.x <= max_corner.x && bounding_box_max.y <= max_corner.y } + /// Returns an `Iterator` containing all possible parametric `t`-values at the given `x`-coordinate. + pub fn find_tvalues_for_x(&self, x: f64) -> impl Iterator { + // Compute the roots of the resulting bezier curve + match self.handles { + BezierHandles::Linear => { + // If the transformed linear bezier is on the x-axis, `a` and `b` will both be zero and `solve_linear` will return no roots + let a = self.end.x - self.start.x; + let b = self.start.x - x; + utils::solve_linear(a, b) + } + BezierHandles::Quadratic { handle } => { + let a = self.start.x - 2. * handle.x + self.end.x; + let b = 2. * (handle.x - self.start.x); + let c = self.start.x - x; + + let discriminant = b * b - 4. * a * c; + let two_times_a = 2. * a; + + utils::solve_quadratic(discriminant, two_times_a, b, c) + } + BezierHandles::Cubic { handle_start, handle_end } => { + let start_x = self.start.x; + let a = -start_x + 3. * handle_start.x - 3. * handle_end.x + self.end.x; + let b = 3. * start_x - 6. * handle_start.x + 3. * handle_end.x; + let c = -3. * start_x + 3. * handle_start.x; + let d = start_x - x; + + utils::solve_cubic(a, b, c, d) + } + } + .into_iter() + .filter(|&t| utils::f64_approximately_in_range(t, 0., 1., MAX_ABSOLUTE_DIFFERENCE)) + } + // TODO: Use an `impl Iterator` return type instead of a `Vec` /// Returns list of `t`-values representing the inflection points of the curve. /// The inflection points are defined to be points at which the second derivative of the curve is equal to zero. @@ -281,54 +315,24 @@ impl Bezier { if other.handles == BezierHandles::Linear { // Rotate the bezier and the line by the angle that the line makes with the x axis let line_directional_vector = other.end - other.start; - let angle = line_directional_vector.angle_between(DVec2::new(1., 0.)); + let angle = line_directional_vector.angle_between(DVec2::new(0., 1.)); let rotation_matrix = DMat2::from_angle(angle); let rotated_bezier = self.apply_transformation(|point| rotation_matrix.mul_vec2(point)); let rotated_line = [rotation_matrix.mul_vec2(other.start), rotation_matrix.mul_vec2(other.end)]; // Translate the bezier such that the line becomes aligned on top of the x-axis - let vertical_distance = rotated_line[0].y; - let translated_bezier = rotated_bezier.translate(DVec2::new(0., -vertical_distance)); + let vertical_distance = rotated_line[0].x; + let translated_bezier = rotated_bezier.translate(DVec2::new(-vertical_distance, 0.)); // Compute the roots of the resulting bezier curve - let list_intersection_t = match translated_bezier.handles { - BezierHandles::Linear => { - // If the transformed linear bezier is on the x-axis, `a` and `b` will both be zero and `solve_linear` will return no roots - let a = translated_bezier.end.y - translated_bezier.start.y; - let b = translated_bezier.start.y; - utils::solve_linear(a, b) - } - BezierHandles::Quadratic { handle } => { - let a = translated_bezier.start.y - 2. * handle.y + translated_bezier.end.y; - let b = 2. * (handle.y - translated_bezier.start.y); - let c = translated_bezier.start.y; - - let discriminant = b * b - 4. * a * c; - let two_times_a = 2. * a; - - utils::solve_quadratic(discriminant, two_times_a, b, c) - } - BezierHandles::Cubic { handle_start, handle_end } => { - let start_y = translated_bezier.start.y; - let a = -start_y + 3. * handle_start.y - 3. * handle_end.y + translated_bezier.end.y; - let b = 3. * start_y - 6. * handle_start.y + 3. * handle_end.y; - let c = -3. * start_y + 3. * handle_start.y; - let d = start_y; - - utils::solve_cubic(a, b, c, d) - } - }; + let list_intersection_t = translated_bezier.find_tvalues_for_x(0.); let min = other.start.min(other.end); let max = other.start.max(other.end); return list_intersection_t - .into_iter() // Accept the t value if it is approximately in [0, 1] and if the corresponding coordinates are within the range of the linear line - .filter(|&t| { - utils::f64_approximately_in_range(t, 0., 1., MAX_ABSOLUTE_DIFFERENCE) - && utils::dvec2_approximately_in_range(self.unrestricted_parametric_evaluate(t), min, max, MAX_ABSOLUTE_DIFFERENCE).all() - }) + .filter(|&t| utils::dvec2_approximately_in_range(self.unrestricted_parametric_evaluate(t), min, max, MAX_ABSOLUTE_DIFFERENCE).all()) // Ensure the returned value is within the correct range .map(|t| t.clamp(0., 1.)) .collect::>(); @@ -724,6 +728,62 @@ mod tests { )); } + #[test] + fn test_find_tvalues_for_x() { + struct Assertion { + bezier: Bezier, + x: f64, + ys: &'static [f64], + } + + let assertions = [ + Assertion { + bezier: Bezier::from_linear_coordinates(0., 0., 20., 10.), + x: 5., + ys: &[2.5], + }, + Assertion { + bezier: Bezier::from_quadratic_coordinates(0., 0., 10., 5., 20., 10.), + x: 5., + ys: &[2.5], + }, + Assertion { + bezier: Bezier::from_cubic_coordinates(0., 0., 10., 5., 10., 5., 20., 10.), + x: 5., + ys: &[2.5], + }, + Assertion { + bezier: Bezier::from_cubic_coordinates(90., 70., 25., 25., 175., 175., 110., 130.), + x: 100., + ys: &[100.], + }, + Assertion { + bezier: Bezier::from_cubic_coordinates(90., 70., 25., 25., 175., 175., 110., 130.), + x: 80., + ys: &[63.62683, 74.53867], + }, + Assertion { + bezier: Bezier::from_cubic_coordinates(110., 70., 25., 25., 175., 175., 90., 130.), + x: 100., + ys: &[65.11345, 100., 134.88655], + }, + ]; + + for Assertion { bezier, x, ys } in assertions { + let mut got: Vec = bezier + .find_tvalues_for_x(x) + .map(|t| bezier.evaluate(TValue::Parametric(t))) + .inspect(|p| assert!((p.x - x).abs() < 1e-4, "wrong x-coordinate, got {} expected {x}", p.x)) + .map(|p| p.y) + .collect(); + assert_eq!(got.len(), ys.len()); + got.sort_by(f64::total_cmp); + got.into_iter() + .zip(ys) + .for_each(|(got, &expected)| assert!((got - expected).abs() < 1e-4, "wrong y-coordinate, got {got} expected {expected}")); + } + } + #[test] fn test_inflections() { let bezier = Bezier::from_cubic_coordinates(30., 30., 30., 150., 150., 30., 150., 150.); diff --git a/node-graph/gcore/src/application_io.rs b/node-graph/gcore/src/application_io.rs index 24c82ebb..5616bf46 100644 --- a/node-graph/gcore/src/application_io.rs +++ b/node-graph/gcore/src/application_io.rs @@ -1,20 +1,16 @@ use crate::raster::ImageFrame; -use crate::transform::Transform; -use crate::transform::TransformMut; -use crate::Color; -use crate::Node; -use alloc::sync::Arc; -use dyn_any::StaticType; -use dyn_any::StaticTypeSized; -use glam::DAffine2; +use crate::text::FontCache; +use crate::transform::{Transform, TransformMut}; +use crate::{Color, Node}; +use dyn_any::{StaticType, StaticTypeSized}; + +use alloc::sync::Arc; +use core::fmt::Debug; use core::future::Future; use core::hash::{Hash, Hasher}; use core::pin::Pin; - -use crate::text::FontCache; - -use core::fmt::Debug; +use glam::DAffine2; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/node-graph/gcore/src/graphic_element.rs b/node-graph/gcore/src/graphic_element.rs index bb857809..ce82d587 100644 --- a/node-graph/gcore/src/graphic_element.rs +++ b/node-graph/gcore/src/graphic_element.rs @@ -3,10 +3,10 @@ use crate::vector::VectorData; use crate::{Color, Node}; use dyn_any::{DynAny, StaticType}; +use node_macro::node_fn; use core::ops::{Deref, DerefMut}; use glam::IVec2; -use node_macro::node_fn; pub mod renderer; diff --git a/node-graph/gcore/src/lib.rs b/node-graph/gcore/src/lib.rs index 3f2b35b5..cc57f4e0 100644 --- a/node-graph/gcore/src/lib.rs +++ b/node-graph/gcore/src/lib.rs @@ -146,9 +146,10 @@ impl<'i, 's: 'i, I: 'i, O: 'i, N: Node<'i, I, Output = O> + ?Sized> Node<'i, I> } } +use dyn_any::StaticTypeSized; + use core::pin::Pin; -use dyn_any::StaticTypeSized; #[cfg(feature = "alloc")] impl<'i, I: 'i, O: 'i> Node<'i, I> for Pin + 'i>> { type Output = O; diff --git a/node-graph/gcore/src/raster.rs b/node-graph/gcore/src/raster.rs index 8911c078..e0b36597 100644 --- a/node-graph/gcore/src/raster.rs +++ b/node-graph/gcore/src/raster.rs @@ -14,17 +14,24 @@ pub mod brightness_contrast; #[cfg(not(target_arch = "spirv"))] pub mod brush_cache; pub mod color; +pub mod curve; pub mod discrete_srgb; pub use adjustments::*; -#[cfg(target_arch = "spirv")] -use num_traits::Float; - pub trait Linear { fn from_f32(x: f32) -> Self; fn to_f32(self) -> f32; fn from_f64(x: f64) -> Self; fn to_f64(self) -> f64; + fn lerp(self, other: Self, value: Self) -> Self + where + Self: Sized + Copy, + Self: core::ops::Sub, + Self: core::ops::Mul, + Self: core::ops::Add, + { + self + (other - self) * value + } } #[rustfmt::skip] @@ -191,6 +198,10 @@ pub trait Luminance { } } +pub trait LuminanceMut: Luminance { + fn set_luminance(&mut self, luminance: Self::LuminanceChannel); +} + // TODO: We might rename this to Raster at some point pub trait Sample { type Pixel: Pixel; diff --git a/node-graph/gcore/src/raster/adjustments.rs b/node-graph/gcore/src/raster/adjustments.rs index 8b03d693..cdcba8c7 100644 --- a/node-graph/gcore/src/raster/adjustments.rs +++ b/node-graph/gcore/src/raster/adjustments.rs @@ -1,11 +1,12 @@ -#![allow(clippy::too_many_arguments)] -use super::Color; -use crate::Node; -use core::fmt::Debug; +use super::curve::{Curve, CurveManipulatorGroup, ValueMapperNode}; +use super::{Channel, Color, Node}; + +use bezier_rs::{Bezier, TValue}; use dyn_any::{DynAny, StaticType}; + +use core::fmt::Debug; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; - #[cfg(target_arch = "spirv")] use spirv_std::num_traits::float::Float; @@ -204,7 +205,7 @@ pub struct ExtractAlphaNode; #[node_macro::node_fn(ExtractAlphaNode)] fn extract_alpha_node(color: Color) -> Color { let alpha = color.a(); - Color::from_rgbaf32(alpha, alpha, alpha, 1.0).unwrap() + Color::from_rgbaf32(alpha, alpha, alpha, 1.).unwrap() } #[derive(Debug, Clone, Copy, Default)] @@ -215,7 +216,7 @@ fn extract_opaque_node(color: Color) -> Color { if color.a() == 0. { return color.with_alpha(1.); } - Color::from_rgbaf32(color.r() / color.a(), color.g() / color.a(), color.b() / color.a(), 1.0).unwrap() + Color::from_rgbaf32(color.r() / color.a(), color.g() / color.a(), color.b() / color.a(), 1.).unwrap() } #[derive(Debug, Clone, Copy, Default)] @@ -856,6 +857,52 @@ fn exposure(color: Color, exposure: f32, offset: f32, gamma_correction: f32) -> adjusted.map_rgb(|c: f32| c.clamp(0., 1.)) } +const WINDOW_SIZE: usize = 1024; + +#[derive(Debug, Clone, Copy)] +pub struct GenerateCurvesNode { + curve: Curve, + _channel: core::marker::PhantomData, +} + +#[node_macro::node_fn(GenerateCurvesNode<_Channel>)] +fn generate_curves<_Channel: Channel + super::Linear>(_primary: (), curve: Curve) -> ValueMapperNode<_Channel> { + let [mut pos, mut param]: [[f32; 2]; 2] = [[0.; 2], curve.first_handle]; + let mut lut = vec![_Channel::from_f64(0.); WINDOW_SIZE]; + let end = CurveManipulatorGroup { + anchor: [1.; 2], + handles: [curve.last_handle, [0.; 2]], + }; + for sample in curve.manipulator_groups.iter().chain(core::iter::once(&end)) { + let [x0, y0, x1, y1, x2, y2, x3, y3] = [pos[0], pos[1], param[0], param[1], sample.handles[0][0], sample.handles[0][1], sample.anchor[0], sample.anchor[1]].map(f64::from); + + let bezier = Bezier::from_cubic_coordinates(x0, y0, x1, y1, x2, y2, x3, y3); + + let [left, right] = [pos[0], sample.anchor[0]].map(|c| c.clamp(0., 1.)); + let lut_index_left: usize = (left * (lut.len() - 1) as f32).floor() as _; + let lut_index_right: usize = (right * (lut.len() - 1) as f32).ceil() as _; + for index in lut_index_left..=lut_index_right { + let x = index as f64 / (lut.len() - 1) as f64; + let y = if x <= x0 { + y0 + } else if x >= x3 { + y3 + } else { + bezier.find_tvalues_for_x(x) + .next() + .map(|t| bezier.evaluate(TValue::Parametric(t.clamp(0., 1.))).y) + // a very bad approximation if bezier_rs failes + .unwrap_or_else(|| (x - x0) / (x3 - x0) * (y3 - y0) + y0) + }; + lut[index] = _Channel::from_f64(y); + } + + pos = sample.anchor; + param = sample.handles[1]; + } + ValueMapperNode::new(lut) +} + #[cfg(feature = "alloc")] pub use index_node::IndexNode; diff --git a/node-graph/gcore/src/raster/brightness_contrast.rs b/node-graph/gcore/src/raster/brightness_contrast.rs index 64296c1b..1be131da 100644 --- a/node-graph/gcore/src/raster/brightness_contrast.rs +++ b/node-graph/gcore/src/raster/brightness_contrast.rs @@ -64,6 +64,7 @@ pub struct GenerateBrightnessContrastMapperNode { contrast: Contrast, } +// TODO: Replace this node implementation with one that uses the more generalized Curves adjustment node #[node_macro::node_fn(GenerateBrightnessContrastMapperNode)] fn brightness_contrast_node(_primary: (), brightness: f32, contrast: f32) -> BrightnessContrastMapperNode { // Brightness LUT diff --git a/node-graph/gcore/src/raster/color.rs b/node-graph/gcore/src/raster/color.rs index 55a2c5a7..4abc15ce 100644 --- a/node-graph/gcore/src/raster/color.rs +++ b/node-graph/gcore/src/raster/color.rs @@ -14,7 +14,7 @@ use bytemuck::{Pod, Zeroable}; use super::{ discrete_srgb::{float_to_srgb_u8, srgb_u8_to_float}, - Alpha, AssociatedAlpha, Luminance, Pixel, RGBMut, Rec709Primaries, RGB, SRGB, + Alpha, AssociatedAlpha, Luminance, LuminanceMut, Pixel, RGBMut, Rec709Primaries, RGB, SRGB, }; #[repr(C)] @@ -113,6 +113,12 @@ impl Luminance for Luma { } } +impl LuminanceMut for Luma { + fn set_luminance(&mut self, luminance: Self::LuminanceChannel) { + self.0 = luminance + } +} + impl RGB for Luma { type ColorChannel = f32; #[inline(always)] @@ -230,6 +236,28 @@ impl Luminance for Color { } } +impl LuminanceMut for Color { + fn set_luminance(&mut self, luminance: f32) { + let current = self.luminance(); + // When we have a black-ish color, we just set the color to a grey-scale value. This prohibits a divide-by-0. + if current < f32::EPSILON { + self.red = 0.2126 * luminance; + self.green = 0.7152 * luminance; + self.blue = 0.0722 * luminance; + return; + } + let fac = luminance / current; + // TODO: when we have for example the rgb color (0, 0, 1) and want to + // TODO: do `.set_luminance(1)`, then the actual luminance is not 1 at + // TODO: the end. With no clamp, the resulting color would be + // TODO: (0, 0, 12.8504). The excess should be spread to the other + // TODO: channels, but is currently just clamped away. + self.red = (self.red * fac).clamp(0., 1.); + self.green = (self.green * fac).clamp(0., 1.); + self.blue = (self.blue * fac).clamp(0., 1.); + } +} + impl Rec709Primaries for Color {} impl SRGB for Color {} diff --git a/node-graph/gcore/src/raster/curve.rs b/node-graph/gcore/src/raster/curve.rs new file mode 100644 index 00000000..8316d85a --- /dev/null +++ b/node-graph/gcore/src/raster/curve.rs @@ -0,0 +1,204 @@ +use super::{Channel, Linear, LuminanceMut}; +use crate::Node; + +use dyn_any::{DynAny, StaticType}; + +use core::ops::{Add, Mul, Sub}; + +#[derive(Debug, Clone, PartialEq, DynAny, specta::Type)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Curve { + #[serde(rename = "manipulatorGroups")] + pub manipulator_groups: Vec, + #[serde(rename = "firstHandle")] + pub first_handle: [f32; 2], + #[serde(rename = "lastHandle")] + pub last_handle: [f32; 2], +} + +impl Default for Curve { + fn default() -> Self { + Self { + manipulator_groups: vec![], + first_handle: [0.2; 2], + last_handle: [0.8; 2], + } + } +} + +impl std::hash::Hash for Curve { + fn hash(&self, state: &mut H) { + self.manipulator_groups.hash(state); + [self.first_handle, self.last_handle].iter().flatten().for_each(|f| f.to_bits().hash(state)); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, DynAny, specta::Type)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CurveManipulatorGroup { + pub anchor: [f32; 2], + pub handles: [[f32; 2]; 2], +} + +impl std::hash::Hash for CurveManipulatorGroup { + fn hash(&self, state: &mut H) { + for c in self.handles.iter().chain([&self.anchor]).flatten() { + c.to_bits().hash(state); + } + } +} + +#[derive(Debug)] +pub struct CubicSplines { + pub x: [f32; 4], + pub y: [f32; 4], +} + +impl CubicSplines { + pub fn solve(&self) -> [f32; 4] { + let (x, y) = (&self.x, &self.y); + + // Build an augmented matrix to solve the system of equations using Gaussian elimination + let mut augmented_matrix = [ + [ + 2. / (x[1] - x[0]), + 1. / (x[1] - x[0]), + 0., + 0., + // | + 3. * (y[1] - y[0]) / ((x[1] - x[0]) * (x[1] - x[0])), + ], + [ + 1. / (x[1] - x[0]), + 2. * (1. / (x[1] - x[0]) + 1. / (x[2] - x[1])), + 1. / (x[2] - x[1]), + 0., + // | + 3. * ((y[1] - y[0]) / ((x[1] - x[0]) * (x[1] - x[0])) + (y[2] - y[1]) / ((x[2] - x[1]) * (x[2] - x[1]))), + ], + [ + 0., + 1. / (x[2] - x[1]), + 2. * (1. / (x[2] - x[1]) + 1. / (x[3] - x[2])), + 1. / (x[3] - x[2]), + // | + 3. * ((y[2] - y[1]) / ((x[2] - x[1]) * (x[2] - x[1])) + (y[3] - y[2]) / ((x[3] - x[2]) * (x[3] - x[2]))), + ], + [ + 0., + 0., + 1. / (x[3] - x[2]), + 2. / (x[3] - x[2]), + // | + 3. * (y[3] - y[2]) / ((x[3] - x[2]) * (x[3] - x[2])), + ], + ]; + + // Gaussian elimination: forward elimination + for row in 0..4 { + let pivot_row_index = (row..4) + .max_by(|&a_row, &b_row| { + augmented_matrix[a_row][row] + .abs() + .partial_cmp(&augmented_matrix[b_row][row].abs()) + .unwrap_or(core::cmp::Ordering::Equal) + }) + .unwrap(); + + // Swap the current row with the row that has the largest pivot element + augmented_matrix.swap(row, pivot_row_index); + + // Eliminate the current column in all rows below the current one + for row_below_current in row + 1..4 { + assert!(augmented_matrix[row][row].abs() > core::f32::EPSILON); + + let scale_factor = augmented_matrix[row_below_current][row] / augmented_matrix[row][row]; + for col in row..5 { + augmented_matrix[row_below_current][col] -= augmented_matrix[row][col] * scale_factor + } + } + } + + // Gaussian elimination: back substitution + let mut solutions = [0.; 4]; + for col in (0..4).rev() { + assert!(augmented_matrix[col][col].abs() > core::f32::EPSILON); + + solutions[col] = augmented_matrix[col][4] / augmented_matrix[col][col]; + + for row in (0..col).rev() { + augmented_matrix[row][4] -= augmented_matrix[row][col] * solutions[col]; + augmented_matrix[row][col] = 0.; + } + } + + solutions + } + + pub fn interpolate(&self, input: f32, solutions: &[f32]) -> f32 { + if input <= self.x[0] { + return self.y[0]; + } + if input >= self.x[self.x.len() - 1] { + return self.y[self.x.len() - 1]; + } + + // Find the segment that the input falls between + let mut segment = 1; + while self.x[segment] < input { + segment += 1; + } + let segment_start = segment - 1; + let segment_end = segment; + + // Calculate the output value using quadratic interpolation + let input_value = self.x[segment_start]; + let input_value_prev = self.x[segment_end]; + let output_value = self.y[segment_start]; + let output_value_prev = self.y[segment_end]; + let solutions_value = solutions[segment_start]; + let solutions_value_prev = solutions[segment_end]; + + let output_delta = solutions_value_prev * (input_value - input_value_prev) - (output_value - output_value_prev); + let solution_delta = (output_value - output_value_prev) - solutions_value * (input_value - input_value_prev); + + let input_ratio = (input - input_value_prev) / (input_value - input_value_prev); + let prev_output_ratio = (1. - input_ratio) * output_value_prev; + let output_ratio = input_ratio * output_value; + let quadratic_ratio = input_ratio * (1. - input_ratio) * (output_delta * (1. - input_ratio) + solution_delta * input_ratio); + + let result = prev_output_ratio + output_ratio + quadratic_ratio; + result.clamp(0., 1.) + } +} + +pub struct ValueMapperNode { + lut: Vec, +} + +impl ValueMapperNode { + pub const fn new(lut: Vec) -> Self { + Self { lut } + } +} + +impl<'i, L: LuminanceMut + 'i> Node<'i, L> for ValueMapperNode +where + L::LuminanceChannel: Linear + Copy, + L::LuminanceChannel: Add, + L::LuminanceChannel: Sub, + L::LuminanceChannel: Mul, +{ + type Output = L; + + fn eval(&'i self, mut val: L) -> L { + let luminance: f32 = val.luminance().to_linear(); + let floating_sample_index = luminance * (self.lut.len() - 1) as f32; + let index_in_lut = floating_sample_index.floor() as usize; + let a = self.lut[index_in_lut]; + let b = self.lut[(index_in_lut + 1).clamp(0, self.lut.len() - 1)]; + let result = a.lerp(b, L::LuminanceChannel::from_linear(floating_sample_index.fract())); + val.set_luminance(result); + val + } +} diff --git a/node-graph/gcore/src/text/to_path.rs b/node-graph/gcore/src/text/to_path.rs index 66e22ab7..438c7eaa 100644 --- a/node-graph/gcore/src/text/to_path.rs +++ b/node-graph/gcore/src/text/to_path.rs @@ -1,6 +1,7 @@ use crate::uuid::ManipulatorGroupId; use bezier_rs::{ManipulatorGroup, Subpath}; + use glam::DVec2; use rustybuzz::ttf_parser::{GlyphId, OutlineBuilder}; use rustybuzz::{GlyphBuffer, UnicodeBuffer}; diff --git a/node-graph/gcore/src/vector/generator_nodes.rs b/node-graph/gcore/src/vector/generator_nodes.rs index 99cab79a..bd2d6b26 100644 --- a/node-graph/gcore/src/vector/generator_nodes.rs +++ b/node-graph/gcore/src/vector/generator_nodes.rs @@ -3,6 +3,7 @@ use crate::vector::VectorData; use crate::Node; use bezier_rs::Subpath; + use glam::DVec2; pub struct UnitCircleGenerator; diff --git a/node-graph/gcore/src/vector/vector_data.rs b/node-graph/gcore/src/vector/vector_data.rs index ce1c7849..208525df 100644 --- a/node-graph/gcore/src/vector/vector_data.rs +++ b/node-graph/gcore/src/vector/vector_data.rs @@ -1,5 +1,6 @@ use super::style::{PathStyle, Stroke}; -use crate::{uuid::ManipulatorGroupId, Color}; +use crate::uuid::ManipulatorGroupId; +use crate::Color; use bezier_rs::ManipulatorGroup; use dyn_any::{DynAny, StaticType}; diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs index 1a15e428..cbfcaa20 100644 --- a/node-graph/gcore/src/vector/vector_nodes.rs +++ b/node-graph/gcore/src/vector/vector_nodes.rs @@ -1,7 +1,9 @@ use super::style::{Fill, FillType, Gradient, GradientType, Stroke}; use super::VectorData; use crate::{Color, Node}; + use bezier_rs::Subpath; + use glam::{DAffine2, DVec2}; #[derive(Debug, Clone, Copy)] @@ -138,7 +140,7 @@ fn circular_repeat_vector_data(mut vector_data: VectorData, rotation_offset: f32 pub struct BoundingBoxNode; #[node_macro::node_fn(BoundingBoxNode)] -fn generate_bounding_box(mut vector_data: VectorData) -> VectorData { +fn generate_bounding_box(vector_data: VectorData) -> VectorData { let bounding_box = vector_data.bounding_box().unwrap(); VectorData::from_subpaths(vec![Subpath::new_rect( vector_data.transform.transform_point2(bounding_box[0]), diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 1c5b84d5..b60f329c 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -60,6 +60,7 @@ pub enum TaggedValue { DocumentNode(DocumentNode), GraphicGroup(graphene_core::GraphicGroup), Artboard(graphene_core::Artboard), + Curve(graphene_core::raster::curve::Curve), IVec2(glam::IVec2), SurfaceFrame(graphene_core::SurfaceFrame), } @@ -126,6 +127,7 @@ impl Hash for TaggedValue { Self::DocumentNode(document_node) => document_node.hash(state), Self::GraphicGroup(graphic_group) => graphic_group.hash(state), Self::Artboard(artboard) => artboard.hash(state), + Self::Curve(curve) => curve.hash(state), Self::IVec2(v) => v.hash(state), Self::SurfaceFrame(surface_id) => surface_id.hash(state), } @@ -179,6 +181,7 @@ impl<'a> TaggedValue { TaggedValue::DocumentNode(x) => Box::new(x), TaggedValue::GraphicGroup(x) => Box::new(x), TaggedValue::Artboard(x) => Box::new(x), + TaggedValue::Curve(x) => Box::new(x), TaggedValue::IVec2(x) => Box::new(x), TaggedValue::SurfaceFrame(x) => Box::new(x), } @@ -245,6 +248,7 @@ impl<'a> TaggedValue { TaggedValue::DocumentNode(_) => concrete!(crate::document::DocumentNode), TaggedValue::GraphicGroup(_) => concrete!(graphene_core::GraphicGroup), TaggedValue::Artboard(_) => concrete!(graphene_core::Artboard), + TaggedValue::Curve(_) => concrete!(graphene_core::raster::curve::Curve), TaggedValue::IVec2(_) => concrete!(glam::IVec2), TaggedValue::SurfaceFrame(_) => concrete!(graphene_core::SurfaceFrame), } diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index be7abfce..8e7ed598 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -420,7 +420,7 @@ impl ProtoNetwork { let mut visited = HashSet::new(); let inwards_edges = self.collect_inwards_edges(); - for (id, node) in &self.nodes { + for (id, _node) in &self.nodes { for &dependency in inwards_edges.get(id).unwrap_or(&Vec::new()) { if !visited.contains(&dependency) { dbg!(id, dependency); diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 7b019cbe..8ed5b7b9 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -476,6 +476,43 @@ fn node_registry() -> HashMap), concrete!(ImageFrame), vec![fn_type!(f32), fn_type!(f32), fn_type!(bool)]), )], + vec![ + ( + NodeIdentifier::new("graphene_core::raster::CurvesNode<_>"), + |args| { + use graphene_core::raster::{curve::Curve, GenerateCurvesNode}; + let curve: DowncastBothNode<(), Curve> = DowncastBothNode::new(args[0].clone()); + Box::pin(async move { + let curve = ClonedNode::new(curve.eval(()).await); + + let generate_curves_node = GenerateCurvesNode::::new(curve); + let map_image_frame_node = graphene_std::raster::MapImageNode::new(ValueNode::new(generate_curves_node.eval(()))); + let map_image_frame_node = FutureWrapperNode::new(map_image_frame_node); + let any: DynAnyNode, _, _> = graphene_std::any::DynAnyNode::new(map_image_frame_node); + any.into_type_erased() + }) + }, + NodeIOTypes::new(concrete!(ImageFrame), concrete!(ImageFrame), vec![fn_type!(graphene_core::raster::curve::Curve)]), + ), + // TODO: Use channel split and merge for this instead of using LuminanceMut for the whole color. + ( + NodeIdentifier::new("graphene_core::raster::CurvesNode<_>"), + |args| { + use graphene_core::raster::{curve::Curve, GenerateCurvesNode}; + let curve: DowncastBothNode<(), Curve> = DowncastBothNode::new(args[0].clone()); + Box::pin(async move { + let curve = ClonedNode::new(curve.eval(()).await); + + let generate_curves_node = GenerateCurvesNode::::new(curve); + let map_image_frame_node = graphene_std::raster::MapImageNode::new(ValueNode::new(generate_curves_node.eval(()))); + let map_image_frame_node = FutureWrapperNode::new(map_image_frame_node); + let any: DynAnyNode, _, _> = graphene_std::any::DynAnyNode::new(map_image_frame_node); + any.into_type_erased() + }) + }, + NodeIOTypes::new(concrete!(ImageFrame), concrete!(ImageFrame), vec![fn_type!(graphene_core::raster::curve::Curve)]), + ), + ], raster_node!(graphene_core::raster::OpacityNode<_>, params: [f32]), raster_node!(graphene_core::raster::PosterizeNode<_>, params: [f32]), raster_node!(graphene_core::raster::ExposureNode<_, _, _>, params: [f32, f32, f32]), diff --git a/website/other/bezier-rs-demos/wasm/src/bezier.rs b/website/other/bezier-rs-demos/wasm/src/bezier.rs index 67dfc475..ba122467 100644 --- a/website/other/bezier-rs-demos/wasm/src/bezier.rs +++ b/website/other/bezier-rs-demos/wasm/src/bezier.rs @@ -2,6 +2,7 @@ use crate::svg_drawing::*; use crate::utils::parse_cap; use bezier_rs::{ArcStrategy, ArcsOptions, Bezier, Identifier, TValue, TValueType}; + use glam::DVec2; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*;