From 5123f32355a81b96708901c86f668ccab796b265 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Wed, 13 May 2026 18:39:54 -0700 Subject: [PATCH 1/2] add: f-curve implementation --- .../libraries/core-types/src/animation.rs | 164 ++++++++++++++++++ node-graph/libraries/core-types/src/lib.rs | 1 + 2 files changed, 165 insertions(+) create mode 100644 node-graph/libraries/core-types/src/animation.rs diff --git a/node-graph/libraries/core-types/src/animation.rs b/node-graph/libraries/core-types/src/animation.rs new file mode 100644 index 0000000000..2c43a123c0 --- /dev/null +++ b/node-graph/libraries/core-types/src/animation.rs @@ -0,0 +1,164 @@ +//! Animation Curve implementation based off of Blender's fcurves. +//! + +use kurbo::{CubicBez, ParamCurve, Point}; + +// Every keyframe defines a left handle point for any bezier easings to the left, +// and info defining the behavior to the right hand side of the keyframe +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Keyframe { + /// If None, defaults to knot in the case of a bezier keyframe to the left. + pub left_handle: Option, + pub knot: Point, + pub interp_behavior: InterpolationBehavior, +} +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum InterpolationBehavior { + Bezier { right_handle: Point }, + Constant, + Linear, +} + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct AnimationCurve { + keyframes: Vec, // not public to maintain sorted order +} + +impl AnimationCurve { + pub fn new() -> Self { + Self { keyframes: Vec::new() } + } + + pub fn evaluate(&self, time: f64) -> f64 { + if self.keyframes.is_empty() || !time.is_finite() { + return 0.0; + } + + // keyframes should (hopefully) have finite, real coordinates + let index = self.keyframes.binary_search_by(|kf| kf.knot.x.partial_cmp(&time).unwrap_or(std::cmp::Ordering::Equal)); + + // We are on a keyframe, use its knot + if let Ok(idx) = index { + return self.keyframes[idx].knot.y; + } + + let index = index.unwrap_err(); + + if index == 0 { + return 0.0; + } else if index == self.keyframes.len() { + // unwrap is safe because of the non-empty guard at the top + return self.keyframes.last().unwrap().knot.y; + } + + let segment_start = &self.keyframes[index - 1]; + let segment_end = &self.keyframes[index]; + + match segment_start.interp_behavior { + InterpolationBehavior::Bezier { right_handle } => { + let curve = CubicBez::new(segment_start.knot, right_handle, segment_end.left_handle.unwrap_or_else(|| segment_end.knot), segment_end.knot); + + // Find the value of t where curve.x == time to find the value + //TODO: find proper values for epsilon and k1. The docs suggest 0.2 for k1 but epsilon should be tested with several values + let t = kurbo::common::solve_itp(|t| curve.eval(t).x - time, 0.0, 1.0, 0.00001, 1, 0.2, segment_start.knot.x - time, segment_end.knot.x - time); + + curve.eval(t).y + } + InterpolationBehavior::Constant => segment_start.knot.y, + InterpolationBehavior::Linear => { + let start = segment_start.knot.y; + let end = segment_end.knot.y; + let i = (time - segment_start.knot.x) / (segment_end.knot.x - segment_start.knot.x); + + start + (end - start) * i + } + } + } + + pub fn keyframes(&self) -> &[Keyframe] { + &self.keyframes + } + + pub fn push_keyframe(&mut self, keyframe: Keyframe) { + self.keyframes.push(keyframe); + self.keyframes.sort_by(|lhs, rhs| lhs.knot.x.partial_cmp(&rhs.knot.x).unwrap_or(std::cmp::Ordering::Equal)); + } + pub fn remove_keyframe(&mut self, idx: usize) -> Option { + if idx >= self.keyframes.len() { + return None; + } + Some(self.keyframes.remove(idx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + pub fn out_of_bounds() { + let empty_curve = AnimationCurve::new(); + assert_eq!(empty_curve.evaluate(10.0), 0.0); + + let mut single_kf = AnimationCurve::new(); + single_kf.push_keyframe(Keyframe { + left_handle: None, + knot: Point::new(1.0, 10.0), + interp_behavior: InterpolationBehavior::Constant, + }); + assert_eq!(single_kf.evaluate(0.0), 0.0); + assert_eq!(single_kf.evaluate(2.0), 10.0); + } + + #[test] + pub fn bezier_segment() { + let mut anim_curve = AnimationCurve::new(); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: Point::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Bezier { right_handle: Point::new(0.5, 0.0) }, + }); + anim_curve.push_keyframe(Keyframe { + left_handle: Some(Point::new(0.5, 1.0)), + knot: Point::new(1.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + + assert_eq!(anim_curve.evaluate(0.5), 0.5); + assert!(anim_curve.evaluate(0.25) - 0.104 < 0.01); + assert!(anim_curve.evaluate(0.75) - 0.896 < 0.01); + } + + #[test] + pub fn simple_segments() { + let mut anim_curve = AnimationCurve::new(); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: Point::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Linear, + }); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: Point::new(1.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: Point::new(2.0, 0.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.push_keyframe(Keyframe { + left_handle: None, + knot: Point::new(3.0, 1.0), + interp_behavior: InterpolationBehavior::Constant, + }); + + assert_eq!(anim_curve.evaluate(0.5), 0.5); + assert_eq!(anim_curve.evaluate(0.25), 0.25); + assert_eq!(anim_curve.evaluate(0.75), 0.75); + + assert_eq!(anim_curve.evaluate(2.5), 0.0); + } + + #[test] + pub fn constant_segment() {} +} diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index b251203ab4..7e48e40f33 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -1,5 +1,6 @@ extern crate log; +pub mod animation; pub mod bounds; pub mod consts; pub mod context; From 3ddcda6e77e601c907b4e2a518231eb20cf753ba Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Wed, 13 May 2026 20:48:47 -0700 Subject: [PATCH 2/2] add: proof of concept animation nodes --- node-graph/graph-craft/src/document/value.rs | 2 + .../interpreted-executor/src/node_registry.rs | 4 ++ .../libraries/core-types/src/animation.rs | 70 ++++++++++++++----- node-graph/nodes/gcore/src/animation.rs | 17 +++++ .../nodes/gcore/src/context_modification.rs | 2 + 5 files changed, 79 insertions(+), 16 deletions(-) diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index b5c58a38f2..b5b94b4d64 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -3,6 +3,7 @@ use crate::application_io::PlatformEditorApi; use crate::proto::{Any as DAny, FutureAny}; use brush_nodes::brush_stroke::BrushStroke; use core_types::color::SRGBA8; +use core_types::animation::AnimationCurve; use core_types::list::List; use core_types::transform::Footprint; use core_types::uuid::NodeId; @@ -397,6 +398,7 @@ tagged_value! { VectorModification(Box), ImageData(Image), Resource(graphene_application_io::ResourceHash), + AnimationCurve(AnimationCurve), // ========== // ENUM TYPES // ========== diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 5a43a5a873..e4304b3946 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -1,3 +1,4 @@ +use core_types::animation::AnimationCurve; use dyn_any::StaticType; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::PlatformEditorApi; @@ -167,6 +168,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::raster::adjustments::RedGreenBlue]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::RedGreenBlueAlpha]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::animation::RealTimeMode]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => AnimationCurve]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::NoiseType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::FractalType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster::adjustments::CellularDistanceFunction]), @@ -194,6 +196,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => AttributeDyn, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => AttributeValueDyn, Context => graphene_std::ContextFeatures]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => ListDyn, Context => graphene_std::ContextFeatures]), + async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => AnimationCurve, Context => graphene_std::ContextFeatures]), #[cfg(target_family = "wasm")] async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => CanvasHandle, Context => graphene_std::ContextFeatures]), // ========== @@ -232,6 +235,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Footprint]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => RenderOutput]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => &PlatformEditorApi]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => AnimationCurve]), #[cfg(feature = "gpu")] async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Option]), diff --git a/node-graph/libraries/core-types/src/animation.rs b/node-graph/libraries/core-types/src/animation.rs index 2c43a123c0..7ed28f36c9 100644 --- a/node-graph/libraries/core-types/src/animation.rs +++ b/node-graph/libraries/core-types/src/animation.rs @@ -1,25 +1,56 @@ //! Animation Curve implementation based off of Blender's fcurves. //! +use dyn_any::DynAny; + +use glam::DVec2; +use graphene_hash::CacheHash; use kurbo::{CubicBez, ParamCurve, Point}; // Every keyframe defines a left handle point for any bezier easings to the left, // and info defining the behavior to the right hand side of the keyframe -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Keyframe { /// If None, defaults to knot in the case of a bezier keyframe to the left. - pub left_handle: Option, - pub knot: Point, + pub left_handle: Option, + pub knot: DVec2, pub interp_behavior: InterpolationBehavior, } -#[derive(Debug, Clone, Copy, PartialEq)] +impl Keyframe { + pub fn new_linear(knot: DVec2, left_handle: Option) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Linear, + } + } + pub fn new_constant(knot: DVec2, left_handle: Option) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Constant, + } + } + pub fn new_bezier(knot: DVec2, left_handle: Option, right_handle: DVec2) -> Self { + Self { + left_handle, + knot, + interp_behavior: InterpolationBehavior::Bezier { right_handle }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum InterpolationBehavior { - Bezier { right_handle: Point }, + Bezier { right_handle: DVec2 }, Constant, Linear, } -#[derive(Default, Debug, Clone, PartialEq)] +#[derive(Default, Debug, Clone, PartialEq, DynAny, CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct AnimationCurve { keyframes: Vec, // not public to maintain sorted order } @@ -56,7 +87,14 @@ impl AnimationCurve { match segment_start.interp_behavior { InterpolationBehavior::Bezier { right_handle } => { - let curve = CubicBez::new(segment_start.knot, right_handle, segment_end.left_handle.unwrap_or_else(|| segment_end.knot), segment_end.knot); + let to_point = |vec: DVec2| Point::new(vec.x, vec.y); + + let curve = CubicBez::new( + to_point(segment_start.knot), + to_point(right_handle), + segment_end.left_handle.map(|end| to_point(end)).unwrap_or_else(|| to_point(segment_end.knot)), + to_point(segment_end.knot), + ); // Find the value of t where curve.x == time to find the value //TODO: find proper values for epsilon and k1. The docs suggest 0.2 for k1 but epsilon should be tested with several values @@ -102,7 +140,7 @@ mod tests { let mut single_kf = AnimationCurve::new(); single_kf.push_keyframe(Keyframe { left_handle: None, - knot: Point::new(1.0, 10.0), + knot: DVec2::new(1.0, 10.0), interp_behavior: InterpolationBehavior::Constant, }); assert_eq!(single_kf.evaluate(0.0), 0.0); @@ -114,12 +152,12 @@ mod tests { let mut anim_curve = AnimationCurve::new(); anim_curve.push_keyframe(Keyframe { left_handle: None, - knot: Point::new(0.0, 0.0), - interp_behavior: InterpolationBehavior::Bezier { right_handle: Point::new(0.5, 0.0) }, + knot: DVec2::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Bezier { right_handle: DVec2::new(0.5, 0.0) }, }); anim_curve.push_keyframe(Keyframe { - left_handle: Some(Point::new(0.5, 1.0)), - knot: Point::new(1.0, 1.0), + left_handle: Some(DVec2::new(0.5, 1.0)), + knot: DVec2::new(1.0, 1.0), interp_behavior: InterpolationBehavior::Constant, }); @@ -133,22 +171,22 @@ mod tests { let mut anim_curve = AnimationCurve::new(); anim_curve.push_keyframe(Keyframe { left_handle: None, - knot: Point::new(0.0, 0.0), + knot: DVec2::new(0.0, 0.0), interp_behavior: InterpolationBehavior::Linear, }); anim_curve.push_keyframe(Keyframe { left_handle: None, - knot: Point::new(1.0, 1.0), + knot: DVec2::new(1.0, 1.0), interp_behavior: InterpolationBehavior::Constant, }); anim_curve.push_keyframe(Keyframe { left_handle: None, - knot: Point::new(2.0, 0.0), + knot: DVec2::new(2.0, 0.0), interp_behavior: InterpolationBehavior::Constant, }); anim_curve.push_keyframe(Keyframe { left_handle: None, - knot: Point::new(3.0, 1.0), + knot: DVec2::new(3.0, 1.0), interp_behavior: InterpolationBehavior::Constant, }); diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 4182847d00..d54fa30221 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -1,3 +1,4 @@ +use core_types::animation::{AnimationCurve, Keyframe}; use core_types::list::List; use core_types::transform::Footprint; use core_types::{CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, OwnedContextImpl}; @@ -27,6 +28,22 @@ pub enum AnimationTimeMode { FrameNumber, } +/// Evaluate the value of an animation curve with the given time +#[node_macro::node(category("Animation"))] +fn eval_curve(_: impl Ctx, curve: AnimationCurve, time: f64) -> f64 { + curve.evaluate(time) +} + +/// Contstructs a new AnimationCurves value with default curve +#[node_macro::node(category("Value"))] +fn animation_curve_value(_: impl Ctx, _primary: ()) -> AnimationCurve { + let mut curve = AnimationCurve::new(); + curve.push_keyframe(Keyframe::new_linear(DVec2::new(0.0, 0.0), None)); + curve.push_keyframe(Keyframe::new_constant(DVec2::new(1.0, 360.0), None)); + + curve +} + /// Produces a chosen representation of the current real time and date (in UTC) based on the system clock. #[node_macro::node(category("Animation"))] fn real_time( diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index aa8f72f1d4..1eb4e09669 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -1,4 +1,5 @@ use core::f64; +use core_types::animation::AnimationCurve; use core_types::context::{CloneVarArgs, Context, ContextFeatures, Ctx, ExtractAll}; use core_types::list::{AttributeDyn, AttributeValueDyn, List, ListDyn}; use core_types::transform::Footprint; @@ -40,6 +41,7 @@ async fn context_modification( Context -> AttributeDyn, Context -> AttributeValueDyn, Context -> ListDyn, + Context -> AnimationCurve, )] value: impl Node, Output = T>, /// The parts of the context to keep when evaluating the input value. All other parts are nullified.