From 7fcb6fd40aeae90932bc687194469ef13f97a09c Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Wed, 13 May 2026 18:39:54 -0700 Subject: [PATCH 1/6] 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 878e7f5360..019e7afad1 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 f99365d565a1034af40ccc406a5e31d82299f440 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Wed, 13 May 2026 20:48:47 -0700 Subject: [PATCH 2/6] 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 271ff6e328..ba3720b97b 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::application_io::resource::Resource; use crate::proto::{Any as DAny, FutureAny}; use brush_nodes::brush_stroke::BrushStroke; +use core_types::animation::AnimationCurve; use core_types::color::SRGBA8; use core_types::list::List; use core_types::transform::Footprint; @@ -408,6 +409,7 @@ tagged_value! { VectorModification(Box), ImageData(Image), Resource(graphene_application_io::resource::ResourceId), + 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 650155a017..69a9de2e6a 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; @@ -171,6 +172,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]), @@ -199,6 +201,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]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => &PlatformEditorApi, Context => graphene_std::ContextFeatures]), @@ -241,6 +244,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. From 89fe16f762a0a51ed66cb629e74e19fbbc8ff609 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Mon, 22 Jun 2026 18:32:05 -0700 Subject: [PATCH 3/6] fix: improve f-curves performance and correctness --- .../libraries/core-types/src/animation.rs | 93 ++++++++++++++----- node-graph/nodes/gcore/src/animation.rs | 4 +- 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/node-graph/libraries/core-types/src/animation.rs b/node-graph/libraries/core-types/src/animation.rs index 7ed28f36c9..db08e7664b 100644 --- a/node-graph/libraries/core-types/src/animation.rs +++ b/node-graph/libraries/core-types/src/animation.rs @@ -56,7 +56,7 @@ pub struct AnimationCurve { } impl AnimationCurve { - pub fn new() -> Self { + pub const fn new() -> Self { Self { keyframes: Vec::new() } } @@ -65,7 +65,7 @@ impl AnimationCurve { return 0.0; } - // keyframes should (hopefully) have finite, real coordinates + // keyframes 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 @@ -75,8 +75,9 @@ impl AnimationCurve { let index = index.unwrap_err(); + // Clamp to the first and last knot y-values when x is outside of all keyframes if index == 0 { - return 0.0; + return self.keyframes[0].knot.y; } 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; @@ -87,13 +88,20 @@ impl AnimationCurve { match segment_start.interp_behavior { InterpolationBehavior::Bezier { right_handle } => { - let to_point = |vec: DVec2| Point::new(vec.x, vec.y); + let start = segment_start.knot; + let end = segment_end.knot; + let left_handle = segment_end.left_handle.unwrap_or(end); + + // Clamp the handle x-coordinates of the handles to inside the segment. + // This prevents the curve from folding over itself and having multiple values of t where x(t) == time. + let right_x = right_handle.x.clamp(start.x, end.x); + let left_x = left_handle.x.clamp(right_x, end.x); 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), + Point::new(start.x, start.y), + Point::new(right_x, right_handle.y), + Point::new(left_x, left_handle.y), + Point::new(end.x, end.y), ); // Find the value of t where curve.x == time to find the value @@ -117,10 +125,28 @@ impl AnimationCurve { &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)); + /// Pushes a new keyframe, overwriting one with the same x-value. + /// Returns the index of the keyframe. + /// + /// # Panics + /// + /// This method panics if a keyframe with a non-finite x-coordinate is given. + pub fn insert_keyframe(&mut self, keyframe: Keyframe) -> usize { + assert!(keyframe.knot.x.is_finite(), "Keyframes must have a finite x-coordinate"); + + match self.keyframes.binary_search_by(|kf| kf.knot.x.partial_cmp(&keyframe.knot.x).unwrap_or(std::cmp::Ordering::Equal)) { + // Overwrite a keyframe with the same x-value + Ok(idx) => { + self.keyframes[idx] = keyframe; + idx + } + Err(idx) => { + self.keyframes.insert(idx, keyframe); + idx + } + } } + pub fn remove_keyframe(&mut self, idx: usize) -> Option { if idx >= self.keyframes.len() { return None; @@ -138,53 +164,53 @@ mod tests { assert_eq!(empty_curve.evaluate(10.0), 0.0); let mut single_kf = AnimationCurve::new(); - single_kf.push_keyframe(Keyframe { + single_kf.insert_keyframe(Keyframe { left_handle: None, knot: DVec2::new(1.0, 10.0), interp_behavior: InterpolationBehavior::Constant, }); - assert_eq!(single_kf.evaluate(0.0), 0.0); + assert_eq!(single_kf.evaluate(0.0), 10.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 { + anim_curve.insert_keyframe(Keyframe { left_handle: None, knot: DVec2::new(0.0, 0.0), interp_behavior: InterpolationBehavior::Bezier { right_handle: DVec2::new(0.5, 0.0) }, }); - anim_curve.push_keyframe(Keyframe { + anim_curve.insert_keyframe(Keyframe { left_handle: Some(DVec2::new(0.5, 1.0)), knot: DVec2::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); + assert!((anim_curve.evaluate(0.25) - 0.104).abs() < 0.01); + assert!((anim_curve.evaluate(0.75) - 0.896).abs() < 0.01); } #[test] pub fn simple_segments() { let mut anim_curve = AnimationCurve::new(); - anim_curve.push_keyframe(Keyframe { + anim_curve.insert_keyframe(Keyframe { left_handle: None, knot: DVec2::new(0.0, 0.0), interp_behavior: InterpolationBehavior::Linear, }); - anim_curve.push_keyframe(Keyframe { + anim_curve.insert_keyframe(Keyframe { left_handle: None, knot: DVec2::new(1.0, 1.0), interp_behavior: InterpolationBehavior::Constant, }); - anim_curve.push_keyframe(Keyframe { + anim_curve.insert_keyframe(Keyframe { left_handle: None, knot: DVec2::new(2.0, 0.0), interp_behavior: InterpolationBehavior::Constant, }); - anim_curve.push_keyframe(Keyframe { + anim_curve.insert_keyframe(Keyframe { left_handle: None, knot: DVec2::new(3.0, 1.0), interp_behavior: InterpolationBehavior::Constant, @@ -198,5 +224,28 @@ mod tests { } #[test] - pub fn constant_segment() {} + pub fn constant_segment() { + let mut anim_curve = AnimationCurve::new(); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(0.0, 0.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(1.0, 5.0), + interp_behavior: InterpolationBehavior::Constant, + }); + anim_curve.insert_keyframe(Keyframe { + left_handle: None, + knot: DVec2::new(2.0, -3.0), + interp_behavior: InterpolationBehavior::Constant, + }); + + assert_eq!(anim_curve.evaluate(-1.0), 0.0); + assert_eq!(anim_curve.evaluate(0.0), 0.0); + assert_eq!(anim_curve.evaluate(0.5), 0.0); + assert_eq!(anim_curve.evaluate(1.0), 5.0); + assert_eq!(anim_curve.evaluate(2.0), -3.0); + } } diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index d54fa30221..beb75ca3f3 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -38,8 +38,8 @@ fn eval_curve(_: impl Ctx, curve: AnimationCurve, time: f64) -> f64 { #[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.insert_keyframe(Keyframe::new_linear(DVec2::new(0.0, 0.0), None)); + curve.insert_keyframe(Keyframe::new_constant(DVec2::new(1.0, 360.0), None)); curve } From 2ef80428b6a5eaa5f74bb7a6065dd0bdbdc4232a Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Mon, 22 Jun 2026 19:30:56 -0700 Subject: [PATCH 4/6] add: Timeline NodeInput variant --- .../utility_types/network_interface.rs | 6 +++--- .../network_interface/resolved_types.rs | 1 + node-graph/graph-craft/src/document.rs | 7 +++++++ node-graph/libraries/core-types/src/uuid.rs | 18 ++++++++++++++++++ node-graph/nodes/gcore/src/animation.rs | 13 +++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 0ac69d677a..a560e97c8c 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -5657,7 +5657,7 @@ impl NodeNetworkInterface { let layer_output = NodeInput::node(layer.to_node(), 0); match post_node_input { - NodeInput::Value { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { + NodeInput::Value { .. } | NodeInput::Timeline { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { // First child in the stack: wire layer output to the post_node input self.set_input_for_import(&post_node, layer_output, network_path); } @@ -5831,7 +5831,7 @@ impl NodeNetworkInterface { if !inserting_into_stack { match post_node_input { // Create a new stack - NodeInput::Value { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { + NodeInput::Value { .. } | NodeInput::Timeline { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { self.create_wire(&OutputConnector::node(layer.to_node(), 0), &post_node, network_path); let final_layer_position = after_move_post_layer_position + IVec2::new(-LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP); @@ -5857,7 +5857,7 @@ impl NodeNetworkInterface { } else { match post_node_input { // Move to the bottom of the stack - NodeInput::Value { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { + NodeInput::Value { .. } | NodeInput::Timeline { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => { let offset = after_move_post_layer_position - previous_layer_position + IVec2::new(0, STACK_VERTICAL_GAP + height_above_layer); self.shift_absolute_node_position(&layer.to_node(), offset, network_path); self.create_wire(&OutputConnector::node(layer.to_node(), 0), &post_node, network_path); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs index 685d32e59b..af5b66378f 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs @@ -160,6 +160,7 @@ impl NodeNetworkInterface { } NodeInput::Value { tagged_value, .. } => TypeSource::TaggedValue(tagged_value.ty()), + NodeInput::Timeline { .. } => TypeSource::Compiled(concrete!(f32)), NodeInput::Import { import_index, .. } => { // Get the input type of the encapsulating node input let Some((encapsulating_node, encapsulating_path)) = network_path.split_last() else { diff --git a/node-graph/graph-craft/src/document.rs b/node-graph/graph-craft/src/document.rs index bff579d6a5..4423d4d661 100644 --- a/node-graph/graph-craft/src/document.rs +++ b/node-graph/graph-craft/src/document.rs @@ -3,6 +3,7 @@ pub mod value; use crate::document::value::TaggedValue; use crate::proto::{ConstructionArgs, ProtoNetwork, ProtoNode}; use core_types::memo::MemoHashGuard; +use core_types::uuid::CurveId; pub use core_types::uuid::NodeId; pub use core_types::uuid::generate_uuid; use core_types::{Context, ContextDependencies, Cow, MemoHash, ProtoNodeIdentifier, Type}; @@ -184,6 +185,9 @@ pub enum NodeInput { exposed: bool, }, + /// An animation curve input from the timeline + Timeline { curve_id: CurveId }, + // TODO: Remove import_type and get type from parent node input /// Input that is provided by the import from the parent network to this document node network. #[serde(alias = "Network")] @@ -259,6 +263,7 @@ impl NodeInput { match self { NodeInput::Node { .. } => true, NodeInput::Value { exposed, .. } => *exposed, + NodeInput::Timeline { .. } => true, NodeInput::Import { .. } => true, NodeInput::Inline(_) => false, NodeInput::Scope(_) => false, @@ -271,6 +276,7 @@ impl NodeInput { NodeInput::Node { .. } => unreachable!("ty() called on NodeInput::Node"), NodeInput::Value { tagged_value, .. } => tagged_value.ty(), NodeInput::Import { import_type, .. } => import_type.clone(), + NodeInput::Timeline { .. } => concrete!(f32), NodeInput::Inline(_) => panic!("ty() called on NodeInput::Inline"), NodeInput::Scope(_) => panic!("ty() called on NodeInput::Scope"), NodeInput::Reflection(_) => concrete!(Metadata), @@ -940,6 +946,7 @@ impl NodeNetwork { *import_index = parent_input_index; } NodeInput::Value { .. } => unreachable!("Value inputs should have been replaced with value nodes"), + NodeInput::Timeline { .. } => unreachable!("Value inputs should have been replaced with value nodes"), NodeInput::Inline(_) => (), NodeInput::Scope(_) => unreachable!("Scope inputs should have been resolved by resolve_scope_inputs_recursive before flattening"), NodeInput::Reflection(_) => unreachable!("Reflection inputs should have been replaced with value nodes"), diff --git a/node-graph/libraries/core-types/src/uuid.rs b/node-graph/libraries/core-types/src/uuid.rs index 838d7da8ce..e00a04ed4d 100644 --- a/node-graph/libraries/core-types/src/uuid.rs +++ b/node-graph/libraries/core-types/src/uuid.rs @@ -84,3 +84,21 @@ impl std::fmt::Display for NodeId { write!(f, "{}", self.0) } } + +#[repr(transparent)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, graphene_hash::CacheHash, PartialOrd, Ord, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CurveId(pub u64); + +impl CurveId { + pub fn new() -> Self { + Self(generate_uuid()) + } +} + +impl std::fmt::Display for CurveId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index beb75ca3f3..0d4c354bc3 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -1,6 +1,7 @@ use core_types::animation::{AnimationCurve, Keyframe}; use core_types::list::List; use core_types::transform::Footprint; +use core_types::uuid::CurveId; use core_types::{CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphic_types::vector_types::GradientStops; @@ -44,6 +45,18 @@ fn animation_curve_value(_: impl Ctx, _primary: ()) -> AnimationCurve { curve } +#[node_macro::node(category("Animation"))] +fn timeline_value(ctx: impl Ctx + ExtractAnimationTime, _primary: (), _timeline_curve_id: u64) -> f64 { + let anim_time = ctx.try_animation_time().unwrap_or_default(); + + // TODO: add ExtractTimelineCurves trait to query the ctx for the correct curve + let mut curve = AnimationCurve::new(); + curve.insert_keyframe(Keyframe::new_linear(DVec2::new(0.0, 0.0), None)); + curve.insert_keyframe(Keyframe::new_constant(DVec2::new(1.0, 1.0), None)); + + curve.evaluate(anim_time) +} + /// 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( From efc2d39dab1d84f8e8d6046c9706d58c08f660fa Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Mon, 22 Jun 2026 21:20:51 -0700 Subject: [PATCH 5/6] feat: transform `NodeInput::Timeline`s to `TimelineEvalNode`s --- node-graph/graph-craft/src/document.rs | 79 ++++++++++++++++++++++++- node-graph/nodes/gcore/src/animation.rs | 3 +- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/node-graph/graph-craft/src/document.rs b/node-graph/graph-craft/src/document.rs index 4423d4d661..b1dff51f3f 100644 --- a/node-graph/graph-craft/src/document.rs +++ b/node-graph/graph-craft/src/document.rs @@ -6,7 +6,7 @@ use core_types::memo::MemoHashGuard; use core_types::uuid::CurveId; pub use core_types::uuid::NodeId; pub use core_types::uuid::generate_uuid; -use core_types::{Context, ContextDependencies, Cow, MemoHash, ProtoNodeIdentifier, Type}; +use core_types::{Context, ContextDependencies, ContextFeatures, Cow, MemoHash, ProtoNodeIdentifier, Type}; use dyn_any::DynAny; use glam::IVec2; use log::Metadata; @@ -886,6 +886,7 @@ impl NodeNetwork { // Replace value inputs with dedicated value nodes if node.implementation != DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("core_types::value::ClonedNode")) { Self::replace_value_inputs_with_nodes(&mut node.inputs, &mut self.nodes, &path, gen_id, map_ids, id); + Self::replace_timeline_inputs_with_nodes(&mut node.inputs, &mut self.nodes, &path, gen_id, map_ids, id); } let DocumentNodeImplementation::Network(mut inner_network) = node.implementation else { @@ -905,6 +906,14 @@ impl NodeNetwork { map_ids, id, ); + Self::replace_timeline_inputs_with_nodes( + &mut inner_network.exports, + &mut inner_network.nodes, + node.original_location.path.as_ref().unwrap_or(&vec![]), + gen_id, + map_ids, + id, + ); // Connect all network inputs to either the parent network nodes, or newly created value nodes for the parent node. inner_network.map_ids(|inner_id| map_ids(id, inner_id)); @@ -946,7 +955,7 @@ impl NodeNetwork { *import_index = parent_input_index; } NodeInput::Value { .. } => unreachable!("Value inputs should have been replaced with value nodes"), - NodeInput::Timeline { .. } => unreachable!("Value inputs should have been replaced with value nodes"), + NodeInput::Timeline { .. } => unreachable!("Timeline inputs should have been replaced with timeline value nodes"), NodeInput::Inline(_) => (), NodeInput::Scope(_) => unreachable!("Scope inputs should have been resolved by resolve_scope_inputs_recursive before flattening"), NodeInput::Reflection(_) => unreachable!("Reflection inputs should have been replaced with value nodes"), @@ -1036,6 +1045,72 @@ impl NodeNetwork { } } + #[inline(never)] + fn replace_timeline_inputs_with_nodes( + inputs: &mut [NodeInput], + collection: &mut FxHashMap, + path: &[NodeId], + gen_id: impl Fn() -> NodeId + Copy, + map_ids: impl Fn(NodeId, NodeId) -> NodeId + Copy, + id: NodeId, + ) { + for input in inputs { + let curve_id = match input { + NodeInput::Timeline { curve_id } => *curve_id, + _ => continue, + }; + + let make_value_node = |collection: &mut FxHashMap, value: TaggedValue, dependant: NodeId| { + let node_id = gen_id(); + let merged_id = map_ids(id, node_id); + let mut node_path = path.to_vec(); + node_path.push(node_id); + collection.insert( + merged_id, + DocumentNode { + inputs: vec![NodeInput::value(value, false)], + implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("core_types::value::ClonedNode")), + original_location: OriginalLocation { + path: Some(node_path), + dependants: vec![vec![dependant]], + ..Default::default() + }, + ..Default::default() + }, + ); + merged_id + }; + + let eval_node_id = gen_id(); + let eval_merged_id = map_ids(id, eval_node_id); + + let primary_node_id = make_value_node(collection, TaggedValue::None, eval_merged_id); + let curve_id_node_id = make_value_node(collection, TaggedValue::U64(curve_id.0), eval_merged_id); + + let mut eval_path = path.to_vec(); + eval_path.push(eval_node_id); + collection.insert( + eval_merged_id, + DocumentNode { + inputs: vec![NodeInput::node(primary_node_id, 0), NodeInput::node(curve_id_node_id, 0)], + implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::animation::TimelineEvalNode")), + context_features: ContextDependencies { + extract: ContextFeatures::ANIMATION_TIME, + inject: ContextFeatures::empty(), + }, + original_location: OriginalLocation { + path: Some(eval_path), + dependants: vec![vec![id]], + ..Default::default() + }, + ..Default::default() + }, + ); + + *input = NodeInput::node(eval_merged_id, 0); + } + } + fn remove_passthrough_node(&mut self, id: NodeId) -> Result<(), String> { let node = self.nodes.get(&id).ok_or_else(|| format!("Node with id {id} does not exist"))?.clone(); if let DocumentNodeImplementation::ProtoNode(ident) = &node.implementation diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 0d4c354bc3..42ce6c14ef 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -1,7 +1,6 @@ use core_types::animation::{AnimationCurve, Keyframe}; use core_types::list::List; use core_types::transform::Footprint; -use core_types::uuid::CurveId; use core_types::{CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphic_types::vector_types::GradientStops; @@ -46,7 +45,7 @@ fn animation_curve_value(_: impl Ctx, _primary: ()) -> AnimationCurve { } #[node_macro::node(category("Animation"))] -fn timeline_value(ctx: impl Ctx + ExtractAnimationTime, _primary: (), _timeline_curve_id: u64) -> f64 { +fn timeline_eval(ctx: impl Ctx + ExtractAnimationTime, _primary: (), _timeline_curve_id: u64) -> f64 { let anim_time = ctx.try_animation_time().unwrap_or_default(); // TODO: add ExtractTimelineCurves trait to query the ctx for the correct curve From 3d256ca8e46c0d36c3f0d40cd581cacc8aebb5c2 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Tue, 23 Jun 2026 22:46:41 -0700 Subject: [PATCH 6/6] add: timeline curve extraction and curves stored in document --- .../document/document_message_handler.rs | 5 ++ .../utility_types/network_interface.rs | 12 ++++- editor/src/node_graph_executor.rs | 4 ++ editor/src/node_graph_executor/runtime.rs | 2 +- node-graph/graph-craft/src/document.rs | 4 +- .../libraries/application-io/src/lib.rs | 4 +- .../libraries/core-types/src/animation.rs | 4 ++ .../libraries/core-types/src/context.rs | 52 +++++++++++++++++++ node-graph/nodes/gcore/src/animation.rs | 10 ++-- node-graph/nodes/gstd/src/render_node.rs | 1 + 10 files changed, 87 insertions(+), 11 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index dcffdac794..bc93d750f7 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -35,6 +35,7 @@ use graph_craft::application_io::wgpu_available; use graph_craft::descriptor; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork}; +use graphene_std::core_types::animation::AnimationCurveMap; use graphene_std::math::quad::Quad; use graphene_std::path_bool_nodes::boolean_intersect; use graphene_std::raster::BlendMode; @@ -3547,6 +3548,10 @@ impl DocumentMessageHandler { } resources.into_iter().collect::>().into_boxed_slice() } + + pub fn timeline_curves(&self) -> &AnimationCurveMap { + self.network_interface.timeline_curves() + } } /// Create a network interface with a single export diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index a560e97c8c..6f5ccdabaf 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -25,6 +25,7 @@ use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork}; use graphene_std::ContextDependencies; use graphene_std::Graphic; +use graphene_std::core_types::animation::AnimationCurveMap; use graphene_std::list::List; use graphene_std::math::quad::Quad; use graphene_std::subpath::Subpath; @@ -47,6 +48,9 @@ pub struct NodeNetworkInterface { network: MemoNetwork, /// Stores all editor information for a NodeNetwork. Should automatically kept in sync by the setter methods when changes to the document network are made. network_metadata: NodeNetworkMetadata, + /// Stores animation curves on the timeline + #[serde(default)] + timeline_curves: AnimationCurveMap, // TODO: Wrap in TransientMetadata Option /// Stores the document network's structural topology. Should automatically kept in sync by the setter methods when changes to the document network are made. #[serde(skip)] @@ -63,6 +67,7 @@ impl Clone for NodeNetworkInterface { Self { network: self.network.clone(), network_metadata: self.network_metadata.clone(), + timeline_curves: self.timeline_curves.clone(), document_metadata: Default::default(), resolved_types: Default::default(), transaction_status: TransactionStatus::Finished, @@ -72,7 +77,7 @@ impl Clone for NodeNetworkInterface { impl PartialEq for NodeNetworkInterface { fn eq(&self, other: &Self) -> bool { - self.network == other.network && self.network_metadata == other.network_metadata + self.network == other.network && self.network_metadata == other.network_metadata && self.timeline_curves == other.timeline_curves } } @@ -163,6 +168,10 @@ impl NodeNetworkInterface { self.selected_nodes_in_nested_network(&[]).unwrap_or_default() } + pub fn timeline_curves(&self) -> &AnimationCurveMap { + &self.timeline_curves + } + /// Get the selected nodes for the network at the network_path pub fn selected_nodes_in_nested_network(&self, network_path: &[NodeId]) -> Option { let Some(network_metadata) = self.network_metadata(network_path) else { @@ -1458,6 +1467,7 @@ impl NodeNetworkInterface { Self { network: MemoNetwork::new(node_network), network_metadata, + timeline_curves: HashMap::new(), document_metadata: DocumentMetadata::default(), resolved_types: ResolvedDocumentNodeTypes::default(), transaction_status: TransactionStatus::Finished, diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 67048f72e8..b40a3cb0a6 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -184,6 +184,7 @@ impl NodeGraphExecutor { viewport, scale: viewport_scale, time, + timeline_curves: Arc::new(document.timeline_curves().clone()), pointer, export_format: graphene_std::application_io::ExportFormat::Raster, render_mode: document.render_mode, @@ -255,6 +256,7 @@ impl NodeGraphExecutor { viewport, scale: viewport_scale, time, + timeline_curves: Arc::new(document.timeline_curves().clone()), pointer, export_format: graphene_std::application_io::ExportFormat::Raster, render_mode, @@ -312,6 +314,7 @@ impl NodeGraphExecutor { viewport, scale: export_config.scale_factor, time: Default::default(), + timeline_curves: Arc::new(document.timeline_curves().clone()), pointer: DVec2::ZERO, export_format, render_mode: document.render_mode, @@ -544,6 +547,7 @@ impl NodeGraphExecutor { viewport, scale, time: Default::default(), + timeline_curves: Arc::new(document.timeline_curves().clone()), pointer: DVec2::ZERO, export_format: ExportFormat::Svg, render_mode: document.render_mode, diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index d799b3817d..63da8d1b07 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -237,7 +237,7 @@ impl NodeRuntime { render_config.export_format = ExportFormat::Svg; } - let result = self.execute_network(render_config).await; + let result = self.execute_network(render_config.clone()).await; let mut responses = VecDeque::new(); // TODO: Only process monitor nodes if the graph has changed, not when only the Footprint changes if !render_config.for_eyedropper { diff --git a/node-graph/graph-craft/src/document.rs b/node-graph/graph-craft/src/document.rs index b1dff51f3f..9a39bdd9e3 100644 --- a/node-graph/graph-craft/src/document.rs +++ b/node-graph/graph-craft/src/document.rs @@ -263,7 +263,7 @@ impl NodeInput { match self { NodeInput::Node { .. } => true, NodeInput::Value { exposed, .. } => *exposed, - NodeInput::Timeline { .. } => true, + NodeInput::Timeline { .. } => false, NodeInput::Import { .. } => true, NodeInput::Inline(_) => false, NodeInput::Scope(_) => false, @@ -1095,7 +1095,7 @@ impl NodeNetwork { inputs: vec![NodeInput::node(primary_node_id, 0), NodeInput::node(curve_id_node_id, 0)], implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::animation::TimelineEvalNode")), context_features: ContextDependencies { - extract: ContextFeatures::ANIMATION_TIME, + extract: ContextFeatures::ANIMATION_TIME | ContextFeatures::TIMELINE_CURVES, inject: ContextFeatures::empty(), }, original_location: OriginalLocation { diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index 3ca7dcbd49..f355dead77 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -1,3 +1,4 @@ +use core_types::animation::AnimationCurveMap; use core_types::transform::Footprint; use dyn_any::{DynAny, StaticType, StaticTypeSized}; use glam::DVec2; @@ -94,12 +95,13 @@ pub struct TimingInformation { pub animation_time: Duration, } -#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny)] +#[derive(Debug, Default, Clone, PartialEq, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct RenderConfig { pub viewport: Footprint, pub scale: f64, pub time: TimingInformation, + pub timeline_curves: Arc, pub pointer: DVec2, #[cfg_attr(feature = "serde", serde(alias = "view_mode"))] pub render_mode: RenderMode, diff --git a/node-graph/libraries/core-types/src/animation.rs b/node-graph/libraries/core-types/src/animation.rs index db08e7664b..3bfcdb0e8a 100644 --- a/node-graph/libraries/core-types/src/animation.rs +++ b/node-graph/libraries/core-types/src/animation.rs @@ -7,6 +7,8 @@ use glam::DVec2; use graphene_hash::CacheHash; use kurbo::{CubicBez, ParamCurve, Point}; +use crate::uuid::CurveId; + // 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, CacheHash)] @@ -155,6 +157,8 @@ impl AnimationCurve { } } +pub type AnimationCurveMap = std::collections::HashMap; + #[cfg(test)] mod tests { use super::*; diff --git a/node-graph/libraries/core-types/src/context.rs b/node-graph/libraries/core-types/src/context.rs index ab8b022239..446d19f565 100644 --- a/node-graph/libraries/core-types/src/context.rs +++ b/node-graph/libraries/core-types/src/context.rs @@ -1,4 +1,6 @@ +use crate::animation::AnimationCurveMap; use crate::transform::Footprint; +use crate::uuid::CurveId; use glam::DVec2; pub use no_std_types::context::{ArcCtx, Ctx}; use std::any::Any; @@ -37,6 +39,11 @@ pub trait ExtractPosition { pub trait ExtractIndex { fn try_index(&self) -> Option>; } +pub trait ExtractTimelineCurves { + fn try_curve(&self, id: CurveId) -> Option<&crate::animation::AnimationCurve>; + fn try_curves(&self) -> Option>; +} + pub trait ExtractVarArgs { // TODO: Consider returning a slice or something like that @@ -64,6 +71,7 @@ pub trait InjectPointerPosition {} pub trait InjectPosition {} pub trait InjectIndex {} pub trait InjectVarArgs {} +pub trait InjectTimelineCurves {} // ================ // EXTRACTALL TRAIT @@ -77,6 +85,7 @@ pub trait ExtractAll: ExtractPointerPosition + ExtractPosition + ExtractIndex + + ExtractTimelineCurves + ExtractVarArgs {} impl< T: ?Sized @@ -87,6 +96,7 @@ impl< + ExtractPointerPosition + ExtractPosition + ExtractIndex + + ExtractTimelineCurves + ExtractVarArgs, > ExtractAll for T { @@ -103,6 +113,7 @@ impl InjectPointerPosition for T {} impl InjectPosition for T {} impl InjectIndex for T {} impl InjectVarArgs for T {} +impl InjectTimelineCurves for T {} // ============= // MODIFY TRAITS @@ -116,6 +127,7 @@ pub trait ModifyPointerPosition: ExtractPointerPosition + InjectPointerPosition pub trait ModifyPosition: ExtractPosition + InjectPosition {} pub trait ModifyIndex: ExtractIndex + InjectIndex {} pub trait ModifyVarArgs: ExtractVarArgs + InjectVarArgs {} +pub trait ModifyTimelineCurves: ExtractTimelineCurves + InjectTimelineCurves {} impl ModifyFootprint for T {} impl ModifyRealTime for T {} @@ -124,6 +136,7 @@ impl ModifyPointerPosit impl ModifyPosition for T {} impl ModifyIndex for T {} impl ModifyVarArgs for T {} +impl ModifyTimelineCurves for T {} // ================ // CONTEXT FEATURES @@ -140,6 +153,7 @@ pub enum ContextFeature { ExtractPosition, ExtractIndex, ExtractVarArgs, + ExtractTimelineCurves, InjectFootprint, InjectRealTime, InjectAnimationTime, @@ -147,6 +161,7 @@ pub enum ContextFeature { InjectPosition, InjectIndex, InjectVarArgs, + InjectTimelineCurves, } // Internal bitflags for fast compiler analysis @@ -162,6 +177,7 @@ bitflags! { const POSITION = 1 << 4; const INDEX = 1 << 5; const VARARGS = 1 << 6; + const TIMELINE_CURVES = 1 << 7; } } @@ -181,6 +197,7 @@ impl ContextFeatures { ContextFeatures::POSITION => "Position", ContextFeatures::INDEX => "Index", ContextFeatures::VARARGS => "VarArgs", + ContextFeatures::TIMELINE_CURVES => "TimelineCurves", _ => "Multiple Features", } } @@ -210,6 +227,7 @@ impl From<&[ContextFeature]> for ContextDependencies { ContextFeature::ExtractPosition => ContextFeatures::POSITION, ContextFeature::ExtractIndex => ContextFeatures::INDEX, ContextFeature::ExtractVarArgs => ContextFeatures::VARARGS, + ContextFeature::ExtractTimelineCurves => ContextFeatures::TIMELINE_CURVES, _ => ContextFeatures::empty(), }; inject |= match feature { @@ -220,6 +238,7 @@ impl From<&[ContextFeature]> for ContextDependencies { ContextFeature::InjectPosition => ContextFeatures::POSITION, ContextFeature::InjectIndex => ContextFeatures::INDEX, ContextFeature::InjectVarArgs => ContextFeatures::VARARGS, + ContextFeature::InjectTimelineCurves => ContextFeatures::TIMELINE_CURVES, _ => ContextFeatures::empty(), }; } @@ -268,6 +287,14 @@ impl ExtractIndex for Option { self.as_ref().and_then(|x| x.try_index()) } } +impl ExtractTimelineCurves for Option { + fn try_curve(&self, id: CurveId) -> Option<&crate::animation::AnimationCurve> { + self.as_ref().and_then(|x| x.try_curve(id)) + } + fn try_curves(&self) -> Option> { + self.as_ref().and_then(|x| x.try_curves()) + } +} impl ExtractVarArgs for Option { fn vararg(&self, index: usize) -> Result, VarArgsResult> { let Some(inner) = self else { return Err(VarArgsResult::NoVarArgs) }; @@ -326,6 +353,14 @@ impl ExtractIndex for Arc { (**self).try_index() } } +impl ExtractTimelineCurves for Arc { + fn try_curve(&self, id: CurveId) -> Option<&crate::animation::AnimationCurve> { + (**self).try_curve(id) + } + fn try_curves(&self) -> Option> { + (**self).try_curves() + } +} impl ExtractVarArgs for Arc { fn vararg(&self, index: usize) -> Result, VarArgsResult> { (**self).vararg(index) @@ -461,6 +496,14 @@ impl ExtractIndex for OwnedContextImpl { self.index.clone().map(|x| x.into_iter()) } } +impl ExtractTimelineCurves for OwnedContextImpl { + fn try_curve(&self, id: CurveId) -> Option<&crate::animation::AnimationCurve> { + self.timeline_curves.as_ref().and_then(|m| m.get(&id)) + } + fn try_curves(&self) -> Option> { + self.timeline_curves.clone() + } +} impl ExtractVarArgs for OwnedContextImpl { fn vararg(&self, index: usize) -> Result, VarArgsResult> { let Some(ref inner) = self.varargs else { @@ -522,6 +565,7 @@ pub struct OwnedContextImpl { // This could be converted into a single enum to save extra bytes index: Option>, varargs: Option>, + timeline_curves: Option>, } impl std::fmt::Debug for OwnedContextImpl { @@ -534,6 +578,7 @@ impl std::fmt::Debug for OwnedContextImpl { .field("pointer_position", &self.pointer_position) .field("index", &self.index) .field("varargs_len", &self.varargs.as_ref().map(|x| x.len())) + .field("timeline_curves", &self.timeline_curves) .finish() } } @@ -578,6 +623,7 @@ impl OwnedContextImpl { let pointer_position = bitflags.contains(ContextFeatures::POINTER_POSITION).then(|| value.try_pointer_position()).flatten(); let position = bitflags.contains(ContextFeatures::POSITION).then(|| value.try_position()).flatten().map(|x| x.collect()); let index = bitflags.contains(ContextFeatures::INDEX).then(|| value.try_index()).flatten().map(|x| x.collect()); + let timeline_curves = bitflags.contains(ContextFeatures::TIMELINE_CURVES).then(|| value.try_curves()).flatten(); OwnedContextImpl { parent, @@ -587,6 +633,7 @@ impl OwnedContextImpl { pointer_position, position, index, + timeline_curves, varargs: None, } } @@ -600,6 +647,7 @@ impl OwnedContextImpl { pointer_position: None, position: None, index: None, + timeline_curves: None, varargs: None, } } @@ -650,6 +698,10 @@ impl OwnedContextImpl { self.pointer_position = Some(pointer_position); self } + pub fn with_timeline_curves(mut self, curves: Arc) -> Self { + self.timeline_curves = Some(curves); + self + } pub fn with_position(mut self, position: DVec2) -> Self { if let Some(current_position) = &mut self.position { current_position.insert(0, position); diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 42ce6c14ef..d41e11257a 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -1,7 +1,8 @@ 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}; +use core_types::uuid::CurveId; +use core_types::{CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, ExtractTimelineCurves, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphic_types::vector_types::GradientStops; use graphic_types::{Artboard, Graphic, Vector}; @@ -45,13 +46,10 @@ fn animation_curve_value(_: impl Ctx, _primary: ()) -> AnimationCurve { } #[node_macro::node(category("Animation"))] -fn timeline_eval(ctx: impl Ctx + ExtractAnimationTime, _primary: (), _timeline_curve_id: u64) -> f64 { +fn timeline_eval(ctx: impl Ctx + ExtractAnimationTime + ExtractTimelineCurves, _primary: (), timeline_curve_id: u64) -> f64 { let anim_time = ctx.try_animation_time().unwrap_or_default(); - // TODO: add ExtractTimelineCurves trait to query the ctx for the correct curve - let mut curve = AnimationCurve::new(); - curve.insert_keyframe(Keyframe::new_linear(DVec2::new(0.0, 0.0), None)); - curve.insert_keyframe(Keyframe::new_constant(DVec2::new(1.0, 1.0), None)); + let Some(curve) = ctx.try_curve(CurveId(timeline_curve_id)) else { return 0.0 }; curve.evaluate(anim_time) } diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index c0eda33a0b..58c4b272bd 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -174,6 +174,7 @@ async fn create_context<'a: 'n>( .with_animation_time(render_config.time.animation_time.as_secs_f64()) .with_pointer_position(render_config.pointer) .with_vararg(Box::new(render_params)) + .with_timeline_curves(render_config.timeline_curves) .into_context(); data.eval(ctx).await