From ac2e536a7ec088062f19d4bfe99fa5d3894fa4be Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Wed, 13 May 2026 18:39:54 -0700 Subject: [PATCH 1/8] 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 0d3a0f0975259b6511128102386fb717078082fe Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Wed, 13 May 2026 20:48:47 -0700 Subject: [PATCH 2/8] 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 27b8c708e4a43839fa69e06abff722ba4be77e99 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Mon, 22 Jun 2026 18:32:05 -0700 Subject: [PATCH 3/8] 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 f0edcba44687ee3dffb3f621e6158e2adbdfcaf1 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Wed, 24 Jun 2026 18:11:11 -0700 Subject: [PATCH 4/8] chore: remove hardcoded animation curve value node --- node-graph/libraries/core-types/src/animation.rs | 3 +-- node-graph/nodes/gcore/src/animation.rs | 10 ---------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/node-graph/libraries/core-types/src/animation.rs b/node-graph/libraries/core-types/src/animation.rs index db08e7664b..7f41086a29 100644 --- a/node-graph/libraries/core-types/src/animation.rs +++ b/node-graph/libraries/core-types/src/animation.rs @@ -1,5 +1,4 @@ -//! Animation Curve implementation based off of Blender's fcurves. -//! +//! Animation Curves use dyn_any::DynAny; diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index beb75ca3f3..9a186659d0 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -34,16 +34,6 @@ 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.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 -} - /// 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 a81d5cfbdaf5f595584c10a7e5472c3151005f6b Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Thu, 25 Jun 2026 16:53:33 -0700 Subject: [PATCH 5/8] fix: ensure keyframes meet preconditions when deserializing AnimationCurve --- .../libraries/core-types/src/animation.rs | 21 +++++++++++++++++-- node-graph/nodes/gcore/src/animation.rs | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/node-graph/libraries/core-types/src/animation.rs b/node-graph/libraries/core-types/src/animation.rs index 7f41086a29..5acfae123b 100644 --- a/node-graph/libraries/core-types/src/animation.rs +++ b/node-graph/libraries/core-types/src/animation.rs @@ -5,6 +5,7 @@ use dyn_any::DynAny; use glam::DVec2; use graphene_hash::CacheHash; use kurbo::{CubicBez, ParamCurve, Point}; +use serde::Deserialize; // 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 @@ -51,6 +52,7 @@ pub enum InterpolationBehavior { #[derive(Default, Debug, Clone, PartialEq, DynAny, CacheHash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct AnimationCurve { + #[serde(deserialize_with = "deserialize_keyframes")] keyframes: Vec, // not public to maintain sorted order } @@ -104,8 +106,7 @@ impl AnimationCurve { ); // 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); + let t = kurbo::common::solve_itp(|t| curve.eval(t).x - time, 0.0, 1.0, 1e-7, 1, 0.2, segment_start.knot.x - time, segment_end.knot.x - time); curve.eval(t).y } @@ -154,6 +155,22 @@ impl AnimationCurve { } } +/// Deserialize a list of keyframes, ensuring they meet all evaluate preconditions +fn deserialize_keyframes<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw_keyframes = >::deserialize(deserializer)?; + let mut temp_curve = AnimationCurve::new(); + // use the existing logic for pushing keyframes to ensure the curve is valid + for kf in raw_keyframes { + if kf.knot.x.is_finite() { + temp_curve.insert_keyframe(kf); + } + } + Ok(temp_curve.keyframes) +} + #[cfg(test)] mod tests { use super::*; diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 9a186659d0..3cd51e2349 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -28,7 +28,7 @@ pub enum AnimationTimeMode { FrameNumber, } -/// Evaluate the value of an animation curve with the given time +/// Evaluate the value of an animation curve #[node_macro::node(category("Animation"))] fn eval_curve(_: impl Ctx, curve: AnimationCurve, time: f64) -> f64 { curve.evaluate(time) From 964f122222af8d203b177aa46b6e5cfdc77f0083 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Fri, 26 Jun 2026 14:54:27 -0700 Subject: [PATCH 6/8] refactor: move animation types into new crate --- Cargo.lock | 14 +++++++++++++ Cargo.toml | 1 + node-graph/graph-craft/Cargo.toml | 1 + node-graph/graph-craft/src/document/value.rs | 2 +- node-graph/interpreted-executor/Cargo.toml | 1 + .../interpreted-executor/src/node_registry.rs | 2 +- node-graph/libraries/animation/Cargo.toml | 21 +++++++++++++++++++ .../src/animation.rs => animation/src/lib.rs} | 0 node-graph/libraries/core-types/src/lib.rs | 1 - node-graph/nodes/gcore/Cargo.toml | 1 + node-graph/nodes/gcore/src/animation.rs | 2 +- .../nodes/gcore/src/context_modification.rs | 2 +- 12 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 node-graph/libraries/animation/Cargo.toml rename node-graph/libraries/{core-types/src/animation.rs => animation/src/lib.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index 5aae8b9592..decc201a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,7 @@ dependencies = [ "dyn-any", "glam", "graph-craft", + "graphene-animation", "graphene-application-io", "graphene-core", "graphene-hash", @@ -2006,6 +2007,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "graphene-animation" +version = "0.1.0" +dependencies = [ + "dyn-any", + "glam", + "graphene-hash", + "kurbo", + "serde", +] + [[package]] name = "graphene-application-io" version = "0.1.0" @@ -2068,6 +2080,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "graphene-animation", "graphene-hash", "graphic-types", "log", @@ -2879,6 +2892,7 @@ dependencies = [ "futures", "glam", "graph-craft", + "graphene-animation", "graphene-core", "graphene-std", "gungraun", diff --git a/Cargo.toml b/Cargo.toml index d31b04f8d2..1fa45f010a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ preprocessor = { path = "node-graph/preprocessor" } math-parser = { path = "libraries/math-parser" } graphene-application-io = { path = "node-graph/libraries/application-io" } graphene-resource = { path = "node-graph/libraries/resources" } +graphene-animation = { path = "node-graph/libraries/animation" } core-types = { path = "node-graph/libraries/core-types" } no-std-types = { path = "node-graph/libraries/no-std-types" } raster-types = { path = "node-graph/libraries/raster-types" } diff --git a/node-graph/graph-craft/Cargo.toml b/node-graph/graph-craft/Cargo.toml index 688a0ea712..2119194a19 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -27,6 +27,7 @@ core-types = { workspace = true, features = ["serde"] } brush-nodes = { workspace = true, features = ["serde"] } graphene-core = { workspace = true, features = ["serde"] } graphene-application-io = { workspace = true, features = ["serde"] } +graphene-animation = { workspace = true, features = ["serde"] } rendering = { workspace = true, features = ["serde"] } raster-nodes = { workspace = true, features = ["serde"] } vector-nodes = { workspace = true, features = ["serde"] } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index ba3720b97b..1f618fb56a 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -3,7 +3,6 @@ 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; @@ -12,6 +11,7 @@ use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type, TypeDe use dyn_any::DynAny; pub use dyn_any::StaticType; pub use glam::{DAffine2, DVec2, IVec2, UVec2}; +use graphene_animation::AnimationCurve; use graphene_application_io::resource::ResourceHash; use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::vector_types::vector::style::{Fill, Gradient, GradientStops}; diff --git a/node-graph/interpreted-executor/Cargo.toml b/node-graph/interpreted-executor/Cargo.toml index 271408240c..429a4ebfd8 100644 --- a/node-graph/interpreted-executor/Cargo.toml +++ b/node-graph/interpreted-executor/Cargo.toml @@ -15,6 +15,7 @@ wasm = ["graphene-std/wasm"] graphene-std = { workspace = true } graph-craft = { workspace = true } graphene-core = { workspace = true } +graphene-animation = { workspace = true } wgpu-executor = { workspace = true } core-types = { workspace = true } dyn-any = { workspace = true } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 69a9de2e6a..e905c14816 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -1,10 +1,10 @@ -use core_types::animation::AnimationCurve; use dyn_any::StaticType; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::PlatformEditorApi; use graph_craft::document::DocumentNode; use graph_craft::document::value::RenderOutput; use graph_craft::proto::{NodeConstructor, TypeErasedBox}; +use graphene_animation::AnimationCurve; use graphene_std::any::DynAnyNode; use graphene_std::application_io::ImageTexture; use graphene_std::brush::brush_stroke::BrushStroke; diff --git a/node-graph/libraries/animation/Cargo.toml b/node-graph/libraries/animation/Cargo.toml new file mode 100644 index 0000000000..2bb2756eac --- /dev/null +++ b/node-graph/libraries/animation/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "graphene-animation" +version = "0.1.0" +edition = "2024" +description = "Animation primitives for Graphene node system" +authors = ["Graphite Authors "] +license = "MIT OR Apache-2.0" + +[dependencies] +serde = { workspace = true, optional = true } +kurbo = { workspace = true } +glam = { workspace = true } +dyn-any = { workspace = true } +graphene-hash = { workspace = true, features = ["derive"] } + +[features] +default = ["serde"] +serde = ["dep:serde"] + +[lints] +workspace = true diff --git a/node-graph/libraries/core-types/src/animation.rs b/node-graph/libraries/animation/src/lib.rs similarity index 100% rename from node-graph/libraries/core-types/src/animation.rs rename to node-graph/libraries/animation/src/lib.rs diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 019e7afad1..878e7f5360 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -1,6 +1,5 @@ extern crate log; -pub mod animation; pub mod bounds; pub mod consts; pub mod context; diff --git a/node-graph/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 1e22058ff7..6f5551a089 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -24,6 +24,7 @@ graphene-hash = { workspace = true } raster-types = { workspace = true } graphic-types = { workspace = true } node-macro = { workspace = true } +graphene-animation = { workspace = true } # Workspace dependencies dyn-any = { workspace = true } diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 3cd51e2349..3d17522418 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -1,8 +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 glam::{DAffine2, DVec2}; +use graphene_animation::AnimationCurve; use graphic_types::vector_types::GradientStops; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index 1eb4e09669..e62deba1bb 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -1,11 +1,11 @@ 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; use core_types::uuid::NodeId; use core_types::{Color, OwnedContextImpl}; use glam::{DAffine2, DVec2}; +use graphene_animation::AnimationCurve; use graphic_types::vector_types::GradientStops; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; From d91d77196462846595ac15fa0bbc0023b4053ee6 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Fri, 26 Jun 2026 15:06:22 -0700 Subject: [PATCH 7/8] fix: evaluate animation curves based on current animation time --- node-graph/nodes/gcore/src/animation.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/node-graph/nodes/gcore/src/animation.rs b/node-graph/nodes/gcore/src/animation.rs index 3d17522418..a087640097 100644 --- a/node-graph/nodes/gcore/src/animation.rs +++ b/node-graph/nodes/gcore/src/animation.rs @@ -30,7 +30,8 @@ pub enum AnimationTimeMode { /// Evaluate the value of an animation curve #[node_macro::node(category("Animation"))] -fn eval_curve(_: impl Ctx, curve: AnimationCurve, time: f64) -> f64 { +fn eval_curve(ctx: impl Ctx + ExtractAnimationTime, _primary: (), curve: AnimationCurve) -> f64 { + let time = ctx.try_animation_time().unwrap_or_default(); curve.evaluate(time) } From acd08a7f7310bf2fc0e342e2dbd6005c263b3c70 Mon Sep 17 00:00:00 2001 From: Gavin-Niederman Date: Fri, 26 Jun 2026 20:12:02 -0700 Subject: [PATCH 8/8] add: AnimationCurve preprocessing and node input visiting --- node-graph/preprocessor/src/lib.rs | 119 +++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 31 deletions(-) diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 4e543a7b7a..9442634ffb 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -23,6 +23,7 @@ impl Preprocessor { pub fn preprocess(&self, network: &mut NodeNetwork, resolve_resource: &dyn Fn(ResourceId) -> Option) -> Result<(), PreprocessorError> { self.insert_inject_scopes(network); self.replace_resource_inputs(network, resolve_resource)?; + self.replace_animation_curve_inputs(network); self.expand_network(network); Ok(()) } @@ -41,47 +42,103 @@ impl Preprocessor { } } - /// Replace every `TaggedValue::Resource(hash)` input with a reference to a freshly inserted `resource` proto node. - fn replace_resource_inputs(&self, network: &mut NodeNetwork, resolve_resource: &dyn Fn(ResourceId) -> Option) -> Result<(), PreprocessorError> { - let mut hash_to_node_id: HashMap = HashMap::new(); - let mut new_resource_nodes: Vec<(NodeId, DocumentNode)> = Vec::new(); + /// Visits each node in the network and passes it to the visitor closure + fn visit_nodes(&self, network: &mut NodeNetwork, mut visitor: impl FnMut(&mut DocumentNode, &mut Vec<(NodeId, DocumentNode)>) -> Result<(), PreprocessorError>) -> Result<(), PreprocessorError> { + // Inner function avoids type recursion issues with dyn + fn inner(network: &mut NodeNetwork, visitor: &mut dyn FnMut(&mut DocumentNode, &mut Vec<(NodeId, DocumentNode)>) -> Result<(), PreprocessorError>) -> Result<(), PreprocessorError> { + let mut inserts = Vec::new(); - for node in network.nodes.values_mut() { - if let DocumentNodeImplementation::Network(nested) = &mut node.implementation { - self.replace_resource_inputs(nested, resolve_resource)?; - continue; + for node in network.nodes.values_mut() { + if let DocumentNodeImplementation::Network(nested) = &mut node.implementation { + inner(nested, visitor)?; + continue; + } + + visitor(node, &mut inserts)?; } - if matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == platform_application_io::resource::IDENTIFIER) { - continue; + for (id, node) in inserts { + network.nodes.insert(id, node); + } + + Ok(()) + } + + inner(network, &mut visitor) + } + + /// Visits each input, excluding nodes with a given identifier + fn visit_inputs_exclude( + &self, + network: &mut NodeNetwork, + exclude: &ProtoNodeIdentifier, + mut visitor: impl FnMut(&mut NodeInput, &mut Vec<(NodeId, DocumentNode)>) -> Result<(), PreprocessorError>, + ) -> Result<(), PreprocessorError> { + self.visit_nodes(network, |node, inserts| { + if matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if identifier == exclude) { + return Ok(()); } for input in node.inputs.iter_mut() { - let NodeInput::Value { tagged_value, .. } = input else { continue }; - let TaggedValue::Resource(resource_id) = **tagged_value else { continue }; + visitor(input, inserts)?; + } + + Ok(()) + }) + } - let Some(hash) = resolve_resource(resource_id) else { - return Err(PreprocessorError::ResourceNotFound(resource_id)); + /// Replace every `TaggedValue::AnimationCurve(curve)` input with a reference to a freshly inserted `eval_curve` proto node. + fn replace_animation_curve_inputs(&self, network: &mut NodeNetwork) { + self.visit_inputs_exclude(network, &graphene_core::animation::eval_curve::IDENTIFIER, |input, new_curve_nodes| { + let NodeInput::Value { tagged_value, .. } = input else { return Ok(()) }; + let TaggedValue::AnimationCurve(ref curve) = **tagged_value else { return Ok(()) }; + + let id = NodeId::new(); + let resource_node = DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::AnimationCurve(curve.clone()), false)], + implementation: DocumentNodeImplementation::ProtoNode(graphene_core::animation::eval_curve::IDENTIFIER), + context_features: ContextDependencies { + extract: ContextFeatures::ANIMATION_TIME, + inject: ContextFeatures::empty(), + }, + ..Default::default() + }; + new_curve_nodes.push((id, resource_node)); + + *input = NodeInput::node(id, 0); + + Ok(()) + }) + .unwrap(); // infallible + } + + /// Replace every `TaggedValue::Resource(hash)` input with a reference to a freshly inserted `resource` proto node. + fn replace_resource_inputs(&self, network: &mut NodeNetwork, resolve_resource: &dyn Fn(ResourceId) -> Option) -> Result<(), PreprocessorError> { + let mut hash_to_node_id: HashMap = HashMap::new(); + + self.visit_inputs_exclude(network, &platform_application_io::resource::IDENTIFIER, |input, new_resource_nodes| { + let NodeInput::Value { tagged_value, .. } = input else { return Ok(()) }; + let TaggedValue::Resource(resource_id) = **tagged_value else { return Ok(()) }; + + let Some(hash) = resolve_resource(resource_id) else { + return Err(PreprocessorError::ResourceNotFound(resource_id)); + }; + + let resource_id = *hash_to_node_id.entry(hash).or_insert_with(|| { + let id = NodeId::new(); + let resource_node = DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::ResourceHash(hash), false), NodeInput::scope("editor-api")], + implementation: DocumentNodeImplementation::ProtoNode(platform_application_io::resource::IDENTIFIER), + ..Default::default() }; + new_resource_nodes.push((id, resource_node)); + id + }); - let resource_id = *hash_to_node_id.entry(hash).or_insert_with(|| { - let id = NodeId::new(); - let resource_node = DocumentNode { - inputs: vec![NodeInput::value(TaggedValue::ResourceHash(hash), false), NodeInput::scope("editor-api")], - implementation: DocumentNodeImplementation::ProtoNode(platform_application_io::resource::IDENTIFIER), - ..Default::default() - }; - new_resource_nodes.push((id, resource_node)); - id - }); - - *input = NodeInput::node(resource_id, 0); - } - } + *input = NodeInput::node(resource_id, 0); - for (id, node) in new_resource_nodes { - network.nodes.insert(id, node); - } + Ok(()) + })?; Ok(()) }