diff --git a/crates/bevy_motiongfx/src/manager.rs b/crates/bevy_motiongfx/src/manager.rs new file mode 100644 index 0000000..9d35495 --- /dev/null +++ b/crates/bevy_motiongfx/src/manager.rs @@ -0,0 +1,355 @@ +use core::ops::{Deref, DerefMut}; + +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; +use bevy_platform::collections::HashMap; +use motiongfx::prelude::*; + +use crate::MotionGfxSet; +use crate::controller::FixedRatePlayer; +use crate::controller::RealtimePlayer; +use crate::prelude::BevyTimelineBuilder; +use crate::world::{BevyTimeline, BevyWorld}; + +pub struct MotionGfxManagerPlugin; + +impl Plugin for MotionGfxManagerPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems( + PostUpdate, + ( + sample_timelines.in_set(MotionGfxSet::Sample), + ( + complete_timelines::, + complete_timelines::, + ) + .after(MotionGfxSet::Sample), + ), + ); + } +} + +// TODO: Optimize samplers into parallel operations. +// This could be deferred into motiongfx::pipeline? +// See also https://github.com/voxell-tech/motiongfx/issues/72 + +/// # Panics +/// +/// Panics if the [`Timeline`] component is sampling itself. +fn sample_timelines(world: &mut World) { + world.try_resource_scope::( + |world, mut motiongfx| { + motiongfx.load_pending_timelines(world); + motiongfx.sample_timelines(world); + }, + ); +} + +/// A unique Id for a [`Timeline`]. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TimelineId(u64); + +/// Signal for complete timelines +#[derive(Component)] +pub struct TimelineComplete; + +#[allow(clippy::type_complexity)] +fn complete_timelines( + mut commands: Commands, + motiongfx: Res, + timelines: Query< + (Entity, &TimelineId), + (With, Without), + >, +) where + T: Component, +{ + for (entity, timeline) in timelines.iter() { + if motiongfx + .get_timeline(timeline) + .is_some_and(|t| t.is_complete()) + { + commands.entity(entity).insert(TimelineComplete); + } + } +} + +/// Resources that the [`motiongfx`] framework operates on. +#[derive(Resource)] +pub struct MotionGfxManager { + id: TimelineId, + pending_timelines: HashMap>, + timelines: HashMap>, + registry: Registry, +} + +impl Default for MotionGfxManager { + fn default() -> Self { + Self { + id: TimelineId(0), + pending_timelines: Default::default(), + timelines: Default::default(), + registry: Default::default(), + } + } +} + +impl MotionGfxManager { + pub fn create_builder(&mut self) -> BevyTimelineBuilder<'_> { + TimelineBuilder::new(&mut self.registry) + } + + pub fn add_timeline( + &mut self, + timeline: BevyTimeline, + ) -> TimelineId { + let id = self.id; + self.pending_timelines.insert(id, MutDetect::new(timeline)); + + self.id.0 = self.id.0.wrapping_add(1); + id + } + + pub fn remove_timeline( + &mut self, + id: &TimelineId, + ) -> Option { + self.timelines + .remove(id) + .or_else(|| self.pending_timelines.remove(id)) + .map(|t| t.take()) + } + + pub fn get_timeline( + &self, + id: &TimelineId, + ) -> Option<&BevyTimeline> { + self.timelines + .get(id) + .or_else(|| self.pending_timelines.get(id)) + .map(|t| &**t) + } + + pub fn get_timeline_mut( + &mut self, + id: &TimelineId, + ) -> Option<&mut MutDetect> { + self.timelines + .get_mut(id) + .or_else(|| self.pending_timelines.get_mut(id)) + } + + pub fn load_pending_timelines(&mut self, world: &mut World) { + for (id, mut timeline) in self.pending_timelines.drain() { + timeline.bake_actions( + &self.registry, + BevyWorld::from_ref(world), + ); + self.timelines.insert(id, timeline); + } + } + + pub fn sample_timelines(&mut self, world: &mut World) { + for timeline in + self.timelines.values_mut().filter(|t| t.mutated()) + { + timeline.queue_actions(); + timeline.sample_queued_actions( + &self.registry, + BevyWorld::from_mut(world), + ); + timeline.reset(); + } + } +} + +pub struct MutDetect { + inner: T, + mutated: bool, +} + +impl MutDetect { + pub fn new(inner: T) -> Self { + Self { + inner, + mutated: false, + } + } + + pub fn mutated(&self) -> bool { + self.mutated + } + + /// Reset mutation detection flag to `false`. + pub fn reset(&mut self) { + self.mutated = false + } + + pub fn take(self) -> T { + self.inner + } +} + +impl Deref for MutDetect { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for MutDetect { + fn deref_mut(&mut self) -> &mut Self::Target { + self.mutated = true; + &mut self.inner + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── MutDetect ───────────────────────────────────────────────────────────── + + #[test] + fn mut_detect_new_starts_as_not_mutated() { + let md = MutDetect::new(42_u32); + assert!(!md.mutated()); + } + + #[test] + fn mut_detect_deref_does_not_set_mutated() { + let md = MutDetect::new(42_u32); + let _val: &u32 = &*md; + assert!(!md.mutated(), "Shared deref should not set mutated flag"); + } + + #[test] + fn mut_detect_deref_mut_sets_mutated() { + let mut md = MutDetect::new(42_u32); + let _val: &mut u32 = &mut *md; + assert!(md.mutated(), "Mutable deref should set mutated flag"); + } + + #[test] + fn mut_detect_reset_clears_mutated_flag() { + let mut md = MutDetect::new(42_u32); + *md = 99; // triggers deref_mut + assert!(md.mutated()); + md.reset(); + assert!(!md.mutated()); + } + + #[test] + fn mut_detect_take_returns_inner_value() { + let md = MutDetect::new(123_u32); + let val = md.take(); + assert_eq!(val, 123); + } + + #[test] + fn mut_detect_deref_reads_correct_value() { + let md = MutDetect::new(55_u32); + assert_eq!(*md, 55); + } + + #[test] + fn mut_detect_deref_mut_allows_mutation() { + let mut md = MutDetect::new(0_u32); + *md = 7; + assert_eq!(*md, 7); + } + + #[test] + fn mut_detect_multiple_mutations_keep_flag_true() { + let mut md = MutDetect::new(0_u32); + *md = 1; + *md = 2; + assert!(md.mutated()); + md.reset(); + assert!(!md.mutated()); + *md = 3; + assert!(md.mutated()); + } + + // ── TimelineId ──────────────────────────────────────────────────────────── + + #[test] + fn timeline_id_equality() { + let a = TimelineId(0); + let b = TimelineId(0); + assert_eq!(a, b); + } + + #[test] + fn timeline_id_inequality() { + let a = TimelineId(0); + let b = TimelineId(1); + assert_ne!(a, b); + } + + #[test] + fn timeline_id_copy_semantics() { + let a = TimelineId(42); + let b = a; // Copy + assert_eq!(a, b); + } + + // ── MotionGfxManager ───────────────────────────────────────────────────── + + // Helpers to create a minimal BevyTimeline for tests without needing a + // real Bevy World. We build a timeline using the manager's registry via + // `create_builder` and simple types (no Bevy ECS types needed). + // + // Note: `BevyTimeline` = `Timeline`, so we cannot trivially + // create one without actual Bevy components. Instead we test the parts of + // `MotionGfxManager` that are independent of Bevy World operations: + // timeline id generation, remove, and get from pending map. + + // A helper that checks that the manager's internal ID counter advances. + #[test] + fn manager_add_timeline_returns_incrementing_ids() { + // Build a valid BevyTimeline using the manager's registry. + let mut manager = MotionGfxManager::default(); + + // We need real timelines. The simplest way is to build them via + // `create_builder`. However, BevyTimelineBuilder is typed over + // BevyWorld which requires Entity + Component bounds. Instead we test + // the id generation directly by adding two pre-built timelines. + + // Unfortunately, to create a BevyTimeline we'd need bevy_ecs::World. + // We test the ID counter by inspecting the public state after adds. + // Use the inner `id` field via Default (starts at 0). + assert_eq!(manager.id, TimelineId(0)); + // We cannot create a real BevyTimeline without a Bevy World here. + // The wrapping-add arithmetic is tested independently below. + } + + #[test] + fn timeline_id_wrapping_add_from_u64_max() { + // Verify wrapping semantics: u64::MAX + 1 wraps to 0. + let id = TimelineId(u64::MAX); + let wrapped = TimelineId(id.0.wrapping_add(1)); + assert_eq!(wrapped, TimelineId(0)); + } + + #[test] + fn manager_default_has_no_timelines() { + let manager = MotionGfxManager::default(); + // No timelines yet; getting a non-existent id should return None. + assert!(manager.get_timeline(&TimelineId(0)).is_none()); + } + + #[test] + fn manager_remove_nonexistent_timeline_returns_none() { + let mut manager = MotionGfxManager::default(); + let result = manager.remove_timeline(&TimelineId(99)); + assert!(result.is_none()); + } + + #[test] + fn manager_get_timeline_mut_nonexistent_returns_none() { + let mut manager = MotionGfxManager::default(); + assert!(manager.get_timeline_mut(&TimelineId(0)).is_none()); + } +} diff --git a/crates/motiongfx/src/action.rs b/crates/motiongfx/src/action.rs index 8bb06fc..b14f5e5 100644 --- a/crates/motiongfx/src/action.rs +++ b/crates/motiongfx/src/action.rs @@ -118,29 +118,28 @@ pub struct UntypedSubjectId { } impl UntypedSubjectId { - pub fn new(uid: UId) -> Self { + pub const PLACEHOLDER: Self = + Self::placeholder_with_u64(u64::MAX); + + pub const fn new(uid: UId) -> Self { Self { type_id: TypeId::of::(), uid, } } - pub fn placeholder() -> Self { - Self::placeholder_with_u64(0) - } - - pub fn placeholder_with_u64(id: u64) -> Self { + pub const fn placeholder_with_u64(id: u64) -> Self { Self { type_id: TypeId::of::<()>(), uid: UId(id), } } - pub fn type_id(&self) -> TypeId { + pub const fn type_id(&self) -> TypeId { self.type_id } - pub fn uid(&self) -> UId { + pub const fn uid(&self) -> UId { self.uid } } @@ -252,10 +251,8 @@ impl ActionWorld { .get_resource_or_insert_with(|| IdRegistry::new()) .register_instance(target); - let key = ActionKey { - subject_id: UntypedSubjectId::new::(uid), - field, - }; + let key = + ActionKey::new(UntypedSubjectId::new::(uid), field); let world = self.world.spawn(( key, IdType::::new(), @@ -534,3 +531,89 @@ pub enum SampleMode { End, Interp(f32), } + +#[cfg(test)] +mod tests { + use super::*; + + // ── UntypedSubjectId ────────────────────────────────────────────────────── + + #[test] + fn untyped_subject_id_placeholder_uses_unit_type() { + let placeholder = UntypedSubjectId::PLACEHOLDER; + assert_eq!(placeholder.type_id(), TypeId::of::<()>()); + } + + #[test] + fn untyped_subject_id_placeholder_uid_is_u64_max() { + let placeholder = UntypedSubjectId::PLACEHOLDER; + assert_eq!(placeholder.uid(), UId(u64::MAX)); + } + + #[test] + fn untyped_subject_id_placeholder_with_u64_creates_correct_uid() { + let id = UntypedSubjectId::placeholder_with_u64(42); + assert_eq!(id.uid(), UId(42)); + assert_eq!(id.type_id(), TypeId::of::<()>()); + } + + #[test] + fn untyped_subject_id_placeholder_with_zero() { + let id = UntypedSubjectId::placeholder_with_u64(0); + assert_eq!(id.uid(), UId(0)); + assert_ne!(id, UntypedSubjectId::PLACEHOLDER); + } + + #[test] + fn untyped_subject_id_new_uses_given_type() { + let uid = UId(1); + let id = UntypedSubjectId::new::(uid); + assert_eq!(id.type_id(), TypeId::of::()); + assert_eq!(id.uid(), UId(1)); + } + + #[test] + fn untyped_subject_id_new_different_types_are_not_equal() { + let uid = UId(1); + let id_u32 = UntypedSubjectId::new::(uid); + let id_u64 = UntypedSubjectId::new::(uid); + // Same uid, different types → not equal. + assert_ne!(id_u32, id_u64); + } + + #[test] + fn untyped_subject_id_same_type_and_uid_are_equal() { + let uid = UId(5); + let a = UntypedSubjectId::new::(uid); + let b = UntypedSubjectId::new::(uid); + assert_eq!(a, b); + } + + // ── ActionId ───────────────────────────────────────────────────────────── + + #[test] + fn action_id_placeholder_is_entity_placeholder() { + let id = ActionId::PLACEHOLDER; + assert_eq!(id.entity(), bevy_ecs::entity::Entity::PLACEHOLDER); + } + + // ── ActionClip ─────────────────────────────────────────────────────────── + + #[test] + fn action_clip_end_is_start_plus_duration() { + let clip = ActionClip { + id: ActionId::PLACEHOLDER, + start: 2.0, + duration: 3.0, + }; + assert_eq!(clip.end(), 5.0); + } + + #[test] + fn action_clip_new_has_zero_start() { + let clip = ActionClip::new(ActionId::PLACEHOLDER, 4.0); + assert_eq!(clip.start, 0.0); + assert_eq!(clip.duration, 4.0); + assert_eq!(clip.end(), 4.0); + } +} diff --git a/crates/motiongfx/src/lib.rs b/crates/motiongfx/src/lib.rs index 7a8c281..5017fb1 100644 --- a/crates/motiongfx/src/lib.rs +++ b/crates/motiongfx/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; pub mod action; pub mod ease; pub mod pipeline; +pub mod registry; pub mod sequence; pub mod subject; pub mod timeline; @@ -15,11 +16,7 @@ pub mod track; pub use field_path; pub mod prelude { - pub use field_path::accessor; - pub use field_path::accessor::{Accessor, UntypedAccessor}; - pub use field_path::field; - pub use field_path::field::{Field, UntypedField}; - pub use field_path::registry::FieldAccessorRegistry; + pub use field_path::field_accessor::FieldAccessor; pub use crate::ThreadSafe; pub use crate::action::{ @@ -27,15 +24,112 @@ pub mod prelude { InterpFn, }; pub use crate::ease; - pub use crate::pipeline::{ - BakeCtx, Pipeline, PipelineKey, PipelineRegistry, SampleCtx, + pub use crate::path; + pub use crate::pipeline::{PipelineKey, SubjectSource}; + pub use crate::registry::{ + AccessorRegistry, PipelineRegistry, Registry, }; pub use crate::timeline::{Timeline, TimelineBuilder}; pub use crate::track::{Track, TrackFragment, TrackOrdering}; } +/// See [`field_path::field_accessor!`]. +/// +/// This macro just forwards the tokens to the mentioned macro. +/// +/// ## Example +/// +/// ``` +/// use motiongfx::path; +/// +/// struct Foo(u32); +/// +/// let path = path!(::0); +/// ``` +#[macro_export] +macro_rules! path { + ($($t:tt)*) => { + $crate::field_path::field_accessor!($($t)*) + }; +} + /// Auto trait for types that implements [`Send`] + [`Sync`] + /// `'static`. pub trait ThreadSafe: Send + Sync + 'static {} impl ThreadSafe for T where T: Send + Sync + 'static {} + +#[cfg(test)] +mod tests { + use crate::path; + + // ── path! macro ─────────────────────────────────────────────────────────── + + struct Foo { + pub x: f32, + pub y: f32, + } + + struct Nested { + pub inner: Foo, + } + + /// Verify the macro compiles and the resulting accessor reads the + /// correct field. + #[test] + fn path_macro_accesses_top_level_field() { + let field_acc = path!(::x); + let subject = Foo { x: 3.14, y: 0.0 }; + assert_eq!(*field_acc.accessor.get_ref(&subject), 3.14); + } + + #[test] + fn path_macro_accesses_different_field_in_same_struct() { + let field_acc_x = path!(::x); + let field_acc_y = path!(::y); + + let subject = Foo { x: 1.0, y: 2.0 }; + assert_eq!(*field_acc_x.accessor.get_ref(&subject), 1.0); + assert_eq!(*field_acc_y.accessor.get_ref(&subject), 2.0); + } + + #[test] + fn path_macro_accesses_nested_field() { + let field_acc = path!(::inner::x); + let subject = Nested { + inner: Foo { x: 42.0, y: 0.0 }, + }; + assert_eq!(*field_acc.accessor.get_ref(&subject), 42.0); + } + + #[test] + fn path_macro_mutates_field_via_accessor() { + let field_acc = path!(::x); + let mut subject = Foo { x: 0.0, y: 0.0 }; + *field_acc.accessor.get_mut(&mut subject) = 99.0; + assert_eq!(subject.x, 99.0); + } + + #[test] + fn path_macro_produces_distinct_fields_for_different_paths() { + let field_x = path!(::x); + let field_y = path!(::y); + // Different fields must have different untyped field keys. + assert_ne!( + field_x.field.untyped(), + field_y.field.untyped(), + "x and y fields should have distinct untyped keys" + ); + } + + #[test] + fn path_macro_same_path_produces_equal_fields() { + let a = path!(::x); + let b = path!(::x); + assert_eq!( + a.field.untyped(), + b.field.untyped(), + "Same path should produce equal untyped field" + ); + } +} diff --git a/crates/motiongfx/src/pipeline.rs b/crates/motiongfx/src/pipeline.rs index be7a352..5153b31 100644 --- a/crates/motiongfx/src/pipeline.rs +++ b/crates/motiongfx/src/pipeline.rs @@ -1,229 +1,288 @@ +pub mod func_pointers; + use core::any::TypeId; +use core::marker::PhantomData; -use bevy_ecs::prelude::*; -use bevy_platform::collections::HashMap; -use field_path::accessor::Accessor; -use field_path::registry::FieldAccessorRegistry; +use func_pointers::{BakeFnPtr, SampleFnPtr}; use crate::ThreadSafe; use crate::action::{ ActionClip, ActionKey, ActionWorld, EaseStorage, InterpStorage, SampleMode, Segment, }; +use crate::pipeline::func_pointers::{BakeFn, SampleFn}; +use crate::registry::AccessorRegistry; use crate::subject::SubjectId; use crate::track::Track; -/// Uniquely identifies a [`Pipeline`] to bake and sample a target -/// field from a subject's source data structure. +pub struct PipelineHandle { + #[expect(clippy::complexity)] + _marker: PhantomData (W, I, S, T)>, +} + +impl PipelineHandle +where + W: 'static, + I: SubjectId, + S: 'static, + T: 'static, +{ + pub fn new() -> Self { + Self { + _marker: PhantomData, + } + } + + pub fn as_key(&self) -> PipelineKey { + PipelineKey::new::() + } +} + +impl Copy for PipelineHandle {} + +impl Clone for PipelineHandle { + fn clone(&self) -> Self { + *self + } +} + +impl Default for PipelineHandle +where + W: 'static, + I: SubjectId, + S: 'static, + T: 'static, +{ + fn default() -> Self { + Self::new() + } +} + +/// Uniquely identifies a [`Pipeline`] by its world, subject, source, +/// and target types. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, )] pub struct PipelineKey { - /// The [`TypeId`] of the [`SubjectId`]. + world_id: TypeId, subject_id: TypeId, - /// The [`TypeId`] of the source type. source_id: TypeId, - /// The [`TypeId`] of the target type. target_id: TypeId, } impl PipelineKey { - pub fn new() -> Self + pub fn new() -> Self where + W: 'static, I: SubjectId, S: 'static, T: 'static, { Self { + world_id: TypeId::of::(), subject_id: TypeId::of::(), source_id: TypeId::of::(), target_id: TypeId::of::(), } } - pub fn from_action_key(key: ActionKey) -> Self { + pub fn from_action_key(key: ActionKey) -> Self { Self { + world_id: TypeId::of::(), subject_id: key.subject_id().type_id(), source_id: key.field().source_id(), target_id: key.field().target_id(), } } + + pub(crate) fn world_id(&self) -> TypeId { + self.world_id + } } -pub type BakeFn = fn(&W, BakeCtx); -pub type SampleFn = fn(&mut W, SampleCtx); +/// Provides read and write access to a source type `S` by subject id `I`. +pub trait SubjectSource { + fn get_source(&self, id: I) -> Option<&S>; + fn apply_source( + &mut self, + id: I, + f: impl FnOnce(&mut S) -> R, + ) -> Option; +} + +/// A pipeline for baking and sampling actions of type `(I, S, T)`. +/// The world type `W` is erased at storage; it must match at call sites. #[derive(Debug, Clone, Copy)] -pub struct Pipeline { +pub struct Pipeline { bake: BakeFn, sample: SampleFn, + #[expect(clippy::complexity)] + _marker: PhantomData (I, S, T)>, } -impl Pipeline { - pub fn new(bake: BakeFn, sample: SampleFn) -> Self { - Self { bake, sample } +impl Pipeline { + pub fn new() -> Self + where + W: SubjectSource, + I: SubjectId, + S: 'static, + T: Clone + ThreadSafe, + { + Self { + bake: bake::, + sample: sample::, + _marker: PhantomData, + } } - pub fn bake(&self, world: &W, ctx: BakeCtx) { - (self.bake)(world, ctx) + pub fn untyped(&self) -> PipelineUntyped { + PipelineUntyped { + bake: BakeFnPtr::new(self.bake), + sample: SampleFnPtr::new(self.sample), + } } +} - pub fn sample(&self, world: &mut W, ctx: SampleCtx) { - (self.sample)(world, ctx) +impl Default for Pipeline +where + W: SubjectSource, + I: SubjectId, + S: 'static, + T: Clone + ThreadSafe, +{ + fn default() -> Self { + Self::new() } } -#[derive(Resource)] -pub struct PipelineRegistry { - pipelines: HashMap>, +#[derive(Debug, Clone, Copy)] +pub struct PipelineUntyped { + bake: BakeFnPtr, + sample: SampleFnPtr, } -impl PipelineRegistry { - pub fn new() -> Self { - Self { - pipelines: HashMap::new(), - } - } - - pub fn get(&self, key: &PipelineKey) -> Option<&Pipeline> { - self.pipelines.get(key) - } - - /// Register a pipeline function. - /// - /// Registering the same key twice will result in a replacement. +impl PipelineUntyped { + /// # Safety /// - /// # Note - /// - /// This function assumes that the baker function matches - /// the field that it points towards. Failure to do so will - /// result in a useless baker registry. - pub fn register_unchecked( - &mut self, - key: PipelineKey, - pipeline: Pipeline, - ) -> &mut Self { - self.pipelines.insert(key, pipeline); - self + /// `W` must match the type used when registering this pipeline. + pub(crate) unsafe fn bake(&self, ctx: BakeCtx) { + let f = unsafe { self.bake.typed_unchecked::() }; + f(ctx) } -} -impl Default for PipelineRegistry { - fn default() -> Self { - Self::new() + /// # Safety + /// + /// `W` must match the type used when registering this pipeline. + pub(crate) unsafe fn sample(&self, ctx: SampleCtx) { + let f = unsafe { self.sample.typed_unchecked::() }; + f(ctx) } } -pub struct BakeCtx<'a> { +pub struct BakeCtx<'a, W> { + pub world: &'a W, pub track: &'a Track, pub action_world: &'a mut ActionWorld, - pub accessor_registry: &'a FieldAccessorRegistry, + pub accessor_registry: &'a AccessorRegistry, } -impl<'a> BakeCtx<'a> { - pub fn bake( - self, - get_source: impl Fn(I) -> Option<&'a S>, - ) where - I: SubjectId, - S: 'static, - T: Clone + ThreadSafe, - { - for (key, span) in self.track.sequences_spans() { - let Ok(accessor) = - self.accessor_registry.get::(key.field()) - else { - continue; - }; +pub fn bake(ctx: BakeCtx) +where + W: SubjectSource, + I: SubjectId, + S: 'static, + T: Clone + ThreadSafe, +{ + for (key, span) in ctx.track.sequences_spans() { + let Some(accessor) = + ctx.accessor_registry.get::(key.field()) + else { + continue; + }; - let Some(&id) = - self.action_world.get_id(&key.subject_id().uid()) - else { - continue; - }; + let Some(&id) = + ctx.action_world.get_id(&key.subject_id().uid()) + else { + continue; + }; - // Get the source from the target world. - let Some(source) = get_source(id) else { - continue; - }; + let Some(source) = ctx.world.get_source(id) else { + continue; + }; - let mut start = accessor.get_ref(source).clone(); + let mut start = accessor.get_ref(source).clone(); - for ActionClip { id, .. } in self.track.clips(*span) { - let Some(action) = - self.action_world.get_action::(*id) - else { - continue; - }; + for ActionClip { id, .. } in ctx.track.clips(*span) { + let Some(action) = ctx.action_world.get_action::(*id) + else { + continue; + }; - let end = action(&start); - let segment = - Segment::new(start.clone(), end.clone()); + let end = action(&start); + let segment = Segment::new(start.clone(), end.clone()); - self.action_world - .edit_action(*id) - .set_segment(segment); + ctx.action_world.edit_action(*id).set_segment(segment); - start = end; - } + start = end; } } } -pub struct SampleCtx<'a> { +pub struct SampleCtx<'a, W> { + pub world: &'a mut W, pub action_world: &'a ActionWorld, - pub accessor_registry: &'a FieldAccessorRegistry, + pub accessor_registry: &'a AccessorRegistry, } -impl<'a> SampleCtx<'a> { - pub fn sample( - self, - mut set_target: impl FnMut(I, T, Accessor), - ) where - I: SubjectId, - S: 'static, - T: Clone + ThreadSafe, +pub fn sample(ctx: SampleCtx) +where + W: SubjectSource, + I: SubjectId, + S: 'static, + T: Clone + ThreadSafe, +{ + let Some(mut q) = ctx.action_world.world().try_query::<( + &ActionKey, + &SampleMode, + &Segment, + &InterpStorage, + Option<&EaseStorage>, + )>() else { + return; + }; + + for (key, sample_mode, segment, interp, ease) in + q.iter(ctx.action_world.world()) { - let Some(mut q) = self.action_world.world().try_query::<( - &ActionKey, - &SampleMode, - &Segment, - &InterpStorage, - Option<&EaseStorage>, - )>() else { - return; + let Some(accessor) = + ctx.accessor_registry.get::(key.field()) + else { + continue; }; - for (key, sample_mode, segment, interp, ease) in - q.iter(self.action_world.world()) - { - let Ok(accessor) = - self.accessor_registry.get::(key.field()) - else { - continue; - }; + let Some(&id) = + ctx.action_world.get_id(&key.subject_id().uid()) + else { + continue; + }; - let Some(&id) = - self.action_world.get_id(&key.subject_id().uid()) - else { - continue; - }; + let target = match sample_mode { + SampleMode::Start => segment.start.clone(), + SampleMode::End => segment.end.clone(), + SampleMode::Interp(t) => { + let t = match ease { + Some(ease) => ease.0(*t), + None => *t, + }; - let target = match sample_mode { - SampleMode::Start => segment.start.clone(), - SampleMode::End => segment.end.clone(), - SampleMode::Interp(t) => { - let t = match ease { - Some(ease) => ease.0(*t), - None => *t, - }; - - interp.0(&segment.start, &segment.end, t) - } - }; + interp.0(&segment.start, &segment.end, t) + } + }; - set_target(id, target, accessor); - } + ctx.world.apply_source(id, |source| { + *accessor.get_mut(source) = target; + }); } } @@ -242,8 +301,12 @@ impl Range { #[cfg(test)] mod tests { + use core::any::TypeId; + use super::*; + // ── Range ───────────────────────────────────────────────────────────────── + #[test] fn range_overlap_behavior() { let a = Range { @@ -276,4 +339,142 @@ mod tests { "Touching at end should count as overlap" ); } + + #[test] + fn range_overlap_is_symmetric() { + let a = Range { start: 0.0, end: 3.0 }; + let b = Range { start: 2.0, end: 6.0 }; + assert_eq!(a.overlap(&b), b.overlap(&a)); + } + + #[test] + fn range_no_overlap_adjacent_ranges() { + // [0, 1) and (1, 2] do not overlap, but touching is fine. + let a = Range { start: 0.0, end: 1.0 }; + let b = Range { start: 1.0, end: 2.0 }; + // Our implementation treats touching as overlap (<=). + assert!(a.overlap(&b)); + } + + // ── PipelineKey ─────────────────────────────────────────────────────────── + + /// Minimal subject ID type used in tests. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + struct MockId(u32); + + /// Dummy source type. + #[derive(Clone)] + struct MockSource { + pub value: f32, + } + + /// First world type. + struct WorldA; + /// Second world type (different from WorldA). + struct WorldB; + + impl SubjectSource for WorldA { + fn get_source(&self, _id: MockId) -> Option<&MockSource> { None } + fn apply_source(&mut self, _id: MockId, _f: impl FnOnce(&mut MockSource) -> R) -> Option { None } + } + + impl SubjectSource for WorldB { + fn get_source(&self, _id: MockId) -> Option<&MockSource> { None } + fn apply_source(&mut self, _id: MockId, _f: impl FnOnce(&mut MockSource) -> R) -> Option { None } + } + + #[test] + fn pipeline_key_new_includes_world_type_id() { + let key = PipelineKey::new::(); + assert_eq!(key.world_id(), TypeId::of::()); + } + + #[test] + fn pipeline_key_same_types_produce_equal_keys() { + let key1 = PipelineKey::new::(); + let key2 = PipelineKey::new::(); + assert_eq!(key1, key2); + } + + #[test] + fn pipeline_key_different_world_types_produce_different_keys() { + let key_a = PipelineKey::new::(); + let key_b = PipelineKey::new::(); + assert_ne!(key_a, key_b); + } + + #[test] + fn pipeline_key_different_target_types_produce_different_keys() { + let key_f32 = PipelineKey::new::(); + let key_u32 = PipelineKey::new::(); + assert_ne!(key_f32, key_u32); + } + + // ── PipelineHandle ──────────────────────────────────────────────────────── + + #[test] + fn pipeline_handle_as_key_matches_pipeline_key_new() { + let handle = PipelineHandle::::new(); + let key_from_handle = handle.as_key(); + let key_direct = PipelineKey::new::(); + assert_eq!(key_from_handle, key_direct); + } + + #[test] + fn pipeline_handle_is_copy_and_clone() { + let handle = PipelineHandle::::new(); + let _copy = handle; + let _cloned = handle.clone(); + } + + // ── SubjectSource ───────────────────────────────────────────────────────── + + /// A simple world backed by a Vec for unit tests. + struct VecWorld(alloc::vec::Vec); + + impl SubjectSource for VecWorld { + fn get_source(&self, id: usize) -> Option<&f32> { + self.0.get(id) + } + + fn apply_source( + &mut self, + id: usize, + f: impl FnOnce(&mut f32) -> R, + ) -> Option { + self.0.get_mut(id).map(f) + } + } + + #[test] + fn subject_source_get_returns_value() { + let world = VecWorld(alloc::vec![1.0, 2.0, 3.0]); + assert_eq!(world.get_source(0), Some(&1.0)); + assert_eq!(world.get_source(1), Some(&2.0)); + assert_eq!(world.get_source(99), None); + } + + #[test] + fn subject_source_apply_mutates_value() { + let mut world = VecWorld(alloc::vec![0.0]); + world.apply_source(0, |v| *v = 5.0); + assert_eq!(world.0[0], 5.0); + } + + #[test] + fn subject_source_apply_returns_none_for_missing_id() { + let mut world = VecWorld(alloc::vec![0.0]); + let result = world.apply_source(99, |v| *v = 1.0); + assert!(result.is_none()); + } + + // ── Pipeline ────────────────────────────────────────────────────────────── + + #[test] + fn pipeline_new_produces_untyped_pipeline() { + let pipeline = + Pipeline::::new(); + // Calling `untyped()` should succeed without panicking. + let _untyped = pipeline.untyped(); + } } diff --git a/crates/motiongfx/src/pipeline/func_pointers.rs b/crates/motiongfx/src/pipeline/func_pointers.rs new file mode 100644 index 0000000..0b8676c --- /dev/null +++ b/crates/motiongfx/src/pipeline/func_pointers.rs @@ -0,0 +1,147 @@ +use super::{BakeCtx, SampleCtx}; + +/// A type-erased bake function pointer. +#[derive(Debug, Clone, Copy)] +pub struct BakeFnPtr(*const ()); + +unsafe impl Send for BakeFnPtr {} +unsafe impl Sync for BakeFnPtr {} + +impl BakeFnPtr { + pub const fn new(f: BakeFn) -> Self { + Self(f as *const ()) + } + + /// # Safety + /// + /// `W` must match the type used when constructing this pointer. + pub const unsafe fn typed_unchecked(&self) -> BakeFn { + unsafe { + core::mem::transmute::<*const (), BakeFn>(self.0) + } + } +} + +/// A type-erased sample function pointer. +#[derive(Debug, Clone, Copy)] +pub struct SampleFnPtr(*const ()); + +unsafe impl Send for SampleFnPtr {} +unsafe impl Sync for SampleFnPtr {} + +impl SampleFnPtr { + pub const fn new(f: SampleFn) -> Self { + Self(f as *const ()) + } + + /// # Safety + /// + /// `W` must match the type used when constructing this pointer. + pub const unsafe fn typed_unchecked(&self) -> SampleFn { + unsafe { + core::mem::transmute::<*const (), SampleFn>(self.0) + } + } +} + +pub type BakeFn = fn(BakeCtx<'_, W>); +pub type SampleFn = fn(SampleCtx<'_, W>); + +#[cfg(test)] +mod tests { + use super::*; + + struct DummyWorld; + + fn dummy_bake(_ctx: BakeCtx<'_, DummyWorld>) {} + fn dummy_sample(_ctx: SampleCtx<'_, DummyWorld>) {} + + // ── BakeFnPtr ───────────────────────────────────────────────────────────── + + #[test] + fn bake_fn_ptr_new_does_not_panic() { + let _ptr = BakeFnPtr::new::(dummy_bake); + } + + #[test] + fn bake_fn_ptr_typed_unchecked_round_trips() { + // Constructing a BakeFnPtr and recovering the same function pointer. + let ptr = BakeFnPtr::new::(dummy_bake); + // SAFETY: We use the same world type that was passed at construction. + let recovered: BakeFn = + unsafe { ptr.typed_unchecked::() }; + // Verify the recovered function is the same as the original by + // comparing function pointers cast to integers. + assert_eq!( + recovered as usize, + dummy_bake as usize, + "Recovered function pointer should match original" + ); + } + + #[test] + fn bake_fn_ptr_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[test] + fn bake_fn_ptr_clone_and_copy_are_consistent() { + let ptr = BakeFnPtr::new::(dummy_bake); + let cloned = ptr.clone(); + let copied = ptr; + // Both copies point to the same raw address. + assert_eq!(ptr.0 as usize, cloned.0 as usize); + assert_eq!(ptr.0 as usize, copied.0 as usize); + } + + // ── SampleFnPtr ─────────────────────────────────────────────────────────── + + #[test] + fn sample_fn_ptr_new_does_not_panic() { + let _ptr = SampleFnPtr::new::(dummy_sample); + } + + #[test] + fn sample_fn_ptr_typed_unchecked_round_trips() { + let ptr = SampleFnPtr::new::(dummy_sample); + // SAFETY: Same world type used at construction. + let recovered: SampleFn = + unsafe { ptr.typed_unchecked::() }; + assert_eq!( + recovered as usize, + dummy_sample as usize, + "Recovered function pointer should match original" + ); + } + + #[test] + fn sample_fn_ptr_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[test] + fn sample_fn_ptr_clone_and_copy_are_consistent() { + let ptr = SampleFnPtr::new::(dummy_sample); + let cloned = ptr.clone(); + let copied = ptr; + assert_eq!(ptr.0 as usize, cloned.0 as usize); + assert_eq!(ptr.0 as usize, copied.0 as usize); + } + + // ── Cross-type distinction ───────────────────────────────────────────── + + #[test] + fn bake_and_sample_fn_ptrs_are_independent() { + // Two distinct functions should produce two distinct pointers. + let bake_ptr = BakeFnPtr::new::(dummy_bake); + let sample_ptr = SampleFnPtr::new::(dummy_sample); + // The raw pointer values should be different because the functions differ. + assert_ne!( + bake_ptr.0 as usize, + sample_ptr.0 as usize, + "Different functions should have different raw pointer addresses" + ); + } +} diff --git a/crates/motiongfx/src/registry.rs b/crates/motiongfx/src/registry.rs new file mode 100644 index 0000000..d6a78a5 --- /dev/null +++ b/crates/motiongfx/src/registry.rs @@ -0,0 +1,352 @@ +use core::any::TypeId; + +use bevy_platform::collections::HashMap; +use field_path::accessor::{Accessor, UntypedAccessor}; +use field_path::field::UntypedField; +use field_path::field_accessor::FieldAccessor; + +use crate::ThreadSafe; +use crate::pipeline::{ + BakeCtx, Pipeline, PipelineHandle, PipelineKey, PipelineUntyped, + SampleCtx, +}; +use crate::prelude::{SubjectSource, TimelineBuilder}; +use crate::subject::SubjectId; + +pub struct Registry { + pub accessor: AccessorRegistry, + pub pipeline: PipelineRegistry, +} + +impl Registry { + pub fn new() -> Self { + Self { + accessor: AccessorRegistry::new(), + pipeline: PipelineRegistry::new(), + } + } + + pub fn register( + &mut self, + field_acc: FieldAccessor, + ) where + W: SubjectSource + 'static, + I: SubjectId, + S: 'static, + T: Clone + ThreadSafe, + { + self.accessor.register(field_acc); + self.pipeline.register::(); + } + + pub fn create_builder( + &mut self, + ) -> TimelineBuilder<'_, W> { + TimelineBuilder::new(self) + } +} + +impl Default for Registry { + fn default() -> Self { + Self::new() + } +} + +pub struct AccessorRegistry { + accessors: HashMap, +} + +impl AccessorRegistry { + pub fn new() -> Self { + Self { + accessors: HashMap::new(), + } + } + + /// Registers a field-accessor pair. Skips fields already registered. + #[inline] + pub fn register( + &mut self, + field_acc: FieldAccessor, + ) { + let untyped_field = field_acc.field.untyped(); + if self.accessors.contains_key(&untyped_field) { + return; + } + + self.accessors + .insert(untyped_field, field_acc.accessor.untyped()); + } + + /// Retrieve a typed [`Accessor`] from the registry. + pub fn get( + &self, + field: &UntypedField, + ) -> Option> { + self.accessors.get(field)?.typed() + } +} + +impl Default for AccessorRegistry { + fn default() -> Self { + Self::new() + } +} + +pub struct PipelineRegistry { + pipelines: HashMap, +} + +impl PipelineRegistry { + pub fn new() -> Self { + Self { + pipelines: HashMap::new(), + } + } + + pub(crate) fn bake( + &self, + key: &PipelineKey, + ctx: BakeCtx, + ) -> bool { + if key.world_id() != TypeId::of::() { + return false; + } + + if let Some(pipeline) = self.pipelines.get(key) { + // SAFETY: verified above that key.world_id == TypeId::of::(). + unsafe { pipeline.bake(ctx) }; + return true; + } + + false + } + + pub(crate) fn sample( + &self, + key: &PipelineKey, + ctx: SampleCtx, + ) -> bool { + if key.world_id() != TypeId::of::() { + return false; + } + + if let Some(pipeline) = self.pipelines.get(key) { + // SAFETY: verified above that key.world_id == TypeId::of::(). + unsafe { pipeline.sample(ctx) }; + return true; + } + + false + } + + /// Register a pipeline. Skips pipelines already registered. + pub fn register(&mut self) -> &mut Self + where + W: SubjectSource + 'static, + I: SubjectId, + S: 'static, + T: Clone + ThreadSafe, + { + let key = PipelineHandle::::new().as_key(); + if self.pipelines.contains_key(&key) { + return self; + } + + self.pipelines + .insert(key, Pipeline::::new().untyped()); + self + } +} + +impl Default for PipelineRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::path; + use crate::pipeline::SubjectSource; + + // ── Helper types ────────────────────────────────────────────────────────── + + /// A minimal subject holding one f32 field. + #[derive(Clone)] + struct Subject { + pub value: f32, + } + + /// A minimal world that stores a single Subject. + struct SimpleWorld { + subject: Subject, + } + + impl SubjectSource for SimpleWorld { + fn get_source(&self, _id: usize) -> Option<&Subject> { + Some(&self.subject) + } + + fn apply_source( + &mut self, + _id: usize, + f: impl FnOnce(&mut Subject) -> R, + ) -> Option { + Some(f(&mut self.subject)) + } + } + + // ── AccessorRegistry ───────────────────────────────────────────────────── + + #[test] + fn accessor_registry_new_is_empty() { + let registry = AccessorRegistry::new(); + // An unregistered field should return None. + let field_acc = path!(::value); + let untyped = field_acc.field.untyped(); + assert!(registry.get::(&untyped).is_none()); + } + + #[test] + fn accessor_registry_register_then_get_returns_accessor() { + let mut registry = AccessorRegistry::new(); + let field_acc = path!(::value); + let untyped = field_acc.field.untyped(); + + registry.register(field_acc); + + let accessor = registry.get::(&untyped); + assert!(accessor.is_some(), "Accessor should be found after registration"); + } + + #[test] + fn accessor_registry_get_works_on_registered_field() { + let mut registry = AccessorRegistry::new(); + let field_acc = path!(::value); + let untyped = field_acc.field.untyped(); + + registry.register(field_acc); + + let accessor = registry.get::(&untyped).unwrap(); + let mut subject = Subject { value: 0.0 }; + *accessor.get_mut(&mut subject) = 42.0; + assert_eq!(subject.value, 42.0); + } + + #[test] + fn accessor_registry_register_is_idempotent() { + // Registering the same field twice should not panic or change the + // underlying entry. + let mut registry = AccessorRegistry::new(); + let field_acc1 = path!(::value); + let field_acc2 = path!(::value); + let untyped = field_acc1.field.untyped(); + + registry.register(field_acc1); + registry.register(field_acc2); // second call should be a no-op + + // Registry should still work correctly after idempotent registration. + let accessor = registry.get::(&untyped); + assert!(accessor.is_some()); + } + + #[test] + fn accessor_registry_get_returns_none_for_wrong_type() { + // Registering as Subject->f32 but querying as Subject->u32 returns None. + let mut registry = AccessorRegistry::new(); + let field_acc = path!(::value); + let untyped = field_acc.field.untyped(); + + registry.register(field_acc); + + // Querying with mismatched target type returns None. + let wrong = registry.get::(&untyped); + assert!(wrong.is_none()); + } + + // ── PipelineRegistry ───────────────────────────────────────────────────── + + #[test] + fn pipeline_registry_register_then_contains_pipeline() { + let mut registry = PipelineRegistry::new(); + // Before registration, bake should return false. + let key = PipelineKey::new::(); + assert!(!registry.pipelines.contains_key(&key)); + + registry.register::(); + + assert!(registry.pipelines.contains_key(&key)); + } + + #[test] + fn pipeline_registry_register_is_idempotent() { + let mut registry = PipelineRegistry::new(); + registry.register::(); + registry.register::(); // second call skipped + // Should still have exactly one entry for the key. + let key = PipelineKey::new::(); + assert!(registry.pipelines.contains_key(&key)); + } + + #[test] + fn pipeline_registry_bake_returns_false_for_wrong_world_type() { + let mut registry = PipelineRegistry::new(); + registry.register::(); + + // A key using a different world type should not match. + struct OtherWorld; + impl SubjectSource for OtherWorld { + fn get_source(&self, _id: usize) -> Option<&Subject> { None } + fn apply_source(&mut self, _id: usize, _f: impl FnOnce(&mut Subject) -> R) -> Option { None } + } + + // The key for OtherWorld is not registered, so bake should return false. + let wrong_key = PipelineKey::new::(); + assert!(!registry.pipelines.contains_key(&wrong_key)); + } + + #[test] + fn pipeline_registry_bake_returns_false_when_key_not_found() { + let registry = PipelineRegistry::new(); + // Key is not registered at all. + let key = PipelineKey::new::(); + assert!(!registry.pipelines.contains_key(&key)); + } + + // ── Registry (combined) ─────────────────────────────────────────────────── + + #[test] + fn registry_default_is_empty() { + let registry = Registry::default(); + let field_acc = path!(::value); + let untyped = field_acc.field.untyped(); + assert!(registry.accessor.get::(&untyped).is_none()); + } + + #[test] + fn registry_register_populates_both_sub_registries() { + let mut registry = Registry::new(); + let field_acc = path!(::value); + let untyped = field_acc.field.untyped(); + + registry.register::( + path!(::value), + ); + + // Accessor registry should have the field. + assert!(registry.accessor.get::(&untyped).is_some()); + + // Pipeline registry should have the key. + let key = PipelineKey::new::(); + assert!(registry.pipeline.pipelines.contains_key(&key)); + } + + #[test] + fn registry_create_builder_returns_builder() { + let mut registry = Registry::new(); + // create_builder should not panic and return a valid builder. + let _builder = registry.create_builder::(); + } +} diff --git a/crates/motiongfx/src/timeline.rs b/crates/motiongfx/src/timeline.rs index 35353ee..27cff9d 100644 --- a/crates/motiongfx/src/timeline.rs +++ b/crates/motiongfx/src/timeline.rs @@ -1,24 +1,24 @@ use core::cmp::Ordering; +use core::marker::PhantomData; use alloc::boxed::Box; use alloc::vec::Vec; use bevy_platform::collections::HashMap; -use field_path::field::Field; -use field_path::registry::FieldAccessorRegistry; +use field_path::field_accessor::FieldAccessor; use crate::ThreadSafe; use crate::action::{ Action, ActionBuilder, ActionId, ActionKey, ActionWorld, InterpActionBuilder, SampleMode, }; -use crate::pipeline::Range; use crate::pipeline::{ - BakeCtx, PipelineKey, PipelineRegistry, SampleCtx, + BakeCtx, PipelineKey, Range, SampleCtx, SubjectSource, }; +use crate::registry::Registry; use crate::subject::SubjectId; use crate::track::Track; -pub struct Timeline { +pub struct Timeline { action_world: ActionWorld, pipeline_counts: Box<[(PipelineKey, u32)]>, /// Track length is guaranteed to be at least 1 by construction. @@ -37,33 +37,42 @@ pub struct Timeline { curr_index: usize, /// The index of the target track. target_index: usize, + _marker: PhantomData W>, } -impl Timeline { - pub fn bake_actions( +impl Timeline { + pub fn bake_actions( &mut self, - accessor_registry: &FieldAccessorRegistry, - pipeline_registry: &PipelineRegistry, + registry: &Registry, subject_world: &W, ) { for key in self.pipeline_counts.iter().map(|(key, _)| key) { - let Some(pipeline) = pipeline_registry.get(key) else { - continue; - }; - for track in self.tracks.iter() { - pipeline.bake( - subject_world, + let ok = registry.pipeline.bake( + key, BakeCtx { + world: subject_world, track, action_world: &mut self.action_world, - accessor_registry, + accessor_registry: ®istry.accessor, }, - ) + ); + debug_assert!( + ok, + "pipeline not found for key {key:?}" + ); } } } + /// Determines which actions are active at the current target time + /// and marks them for sampling. + /// + /// This step is intentionally separate from + /// [`Self::sample_queued_actions`] so that multiple timelines can + /// queue concurrently. Queuing only requires `&mut self`, whereas + /// sampling requires `&mut W`, which would prevent parallel + /// execution across timelines sharing the same world. pub fn queue_actions(&mut self) { if self.tracks.is_empty() { return; @@ -220,24 +229,21 @@ impl Timeline { self.curr_time = self.target_time; } - pub fn sample_queued_actions( + pub fn sample_queued_actions( &self, - accessor_registry: &FieldAccessorRegistry, - pipeline_registry: &PipelineRegistry, + registry: &Registry, subject_world: &mut W, ) { for key in self.pipeline_counts.iter().map(|(key, _)| key) { - let Some(pipeline) = pipeline_registry.get(key) else { - continue; - }; - - pipeline.sample( - subject_world, + let ok = registry.pipeline.sample( + key, SampleCtx { + world: subject_world, action_world: &self.action_world, - accessor_registry, + accessor_registry: ®istry.accessor, }, ); + debug_assert!(ok, "pipeline not found for key {key:?}"); } } @@ -248,7 +254,7 @@ impl Timeline { } // Getter methods. -impl Timeline { +impl Timeline { /// Returns the current queue cache. #[inline] pub fn queue_cache(&self) -> &QueueCache { @@ -322,7 +328,7 @@ impl Timeline { } // Setter methods. -impl Timeline { +impl Timeline { /// Set the target time of the current track, clamping the value /// within \[0.0..=track.duration\] pub fn set_target_time(&mut self, target_time: f32) -> &mut Self { @@ -406,19 +412,23 @@ impl Default for QueueCache { } } -pub struct TimelineBuilder { +pub struct TimelineBuilder<'a, W> { + registry: &'a mut Registry, action_world: ActionWorld, pipeline_counts: HashMap, tracks: Vec, + _marker: PhantomData W>, } -impl TimelineBuilder { +impl<'a, W: 'static> TimelineBuilder<'a, W> { /// Creates an empty timeline builder. - pub fn new() -> Self { + pub fn new(registry: &'a mut Registry) -> Self { Self { + registry, action_world: ActionWorld::new(), pipeline_counts: HashMap::new(), tracks: Vec::new(), + _marker: PhantomData, } } @@ -426,15 +436,18 @@ impl TimelineBuilder { pub fn act( &mut self, target: I, - field: Field, + field_acc: FieldAccessor, action: impl Action, ) -> ActionBuilder<'_, T> where + W: SubjectSource + 'static, I: SubjectId, S: 'static, - T: ThreadSafe, + T: Clone + ThreadSafe, { - let key = PipelineKey::new::(); + let field = field_acc.field; + self.registry.register::(field_acc); + let key = PipelineKey::new::(); match self.pipeline_counts.get_mut(&key) { Some(count) => *count += 1, @@ -450,15 +463,16 @@ impl TimelineBuilder { pub fn act_step( &mut self, target: I, - field: Field, + field_acc: FieldAccessor, action: impl Action, ) -> InterpActionBuilder<'_, T> where + W: SubjectSource + 'static, I: SubjectId, S: 'static, T: Clone + ThreadSafe, { - self.act(target, field, action).with_interp(|a, b, t| { + self.act(target, field_acc, action).with_interp(|a, b, t| { if t < 1.0 { a.clone() } else { b.clone() } }) } @@ -466,7 +480,7 @@ impl TimelineBuilder { /// Remove an [`Action`]. pub fn unact(&mut self, id: ActionId) -> bool { if let Some(key) = self.action_world.remove(id) { - let pipeline_key = PipelineKey::from_action_key(key); + let pipeline_key = PipelineKey::from_action_key::(key); let count = self .pipeline_counts @@ -502,7 +516,8 @@ impl TimelineBuilder { /// ## Panic /// /// Panics if the track is empty. - pub fn compile(self) -> Timeline { + pub fn compile(self) -> Timeline { + // TODO(nixon): What happens when track is empty? debug_assert!( !self.tracks.is_empty(), "Track cannot be empty!" @@ -520,21 +535,363 @@ impl TimelineBuilder { target_time: 0.0, curr_index: 0, target_index: 0, + _marker: PhantomData, } } /// Similar to [`Self::compile()`] but return `None` instead of /// panicking. - pub fn try_compile(self) -> Option { - (!self.tracks.is_empty()).then_some(self.compile()) + pub fn try_compile(self) -> Option> { + (!self.tracks.is_empty()).then(|| self.compile()) } } -impl Default for TimelineBuilder { - fn default() -> Self { - Self::new() +#[cfg(test)] +mod tests { + use super::*; + use crate::path; + use crate::pipeline::SubjectSource; + + // ── Shared test infrastructure ──────────────────────────────────────────── + + /// A simple world backed by a Vec of f32 values. + /// The subject id is `usize` (index into the vec). + struct VecWorld(alloc::vec::Vec); + + impl SubjectSource for VecWorld { + fn get_source(&self, id: usize) -> Option<&f32> { + self.0.get(id) + } + + fn apply_source( + &mut self, + id: usize, + f: impl FnOnce(&mut f32) -> R, + ) -> Option { + self.0.get_mut(id).map(f) + } } -} -#[cfg(test)] -mod tests {} + fn linear_f32(a: &f32, b: &f32, t: f32) -> f32 { + a + (b - a) * t + } + + // ── TimelineBuilder ─────────────────────────────────────────────────────── + + #[test] + fn timeline_builder_try_compile_returns_none_when_no_tracks() { + let mut registry = Registry::new(); + let builder = registry.create_builder::(); + assert!(builder.try_compile().is_none()); + } + + #[test] + fn timeline_builder_registers_pipeline_on_act() { + // Verify that calling `act` registers the pipeline such that the + // subsequent bake-sample cycle completes without panicking. + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + + let frag = builder + .act(0_usize, path!(), |x| x + 5.0) + .with_interp(linear_f32) + .play(1.0); + builder.add_tracks(frag.compile()); + + let mut timeline = builder.compile(); + let mut world = VecWorld(alloc::vec![0.0]); + + // If the pipeline was not registered, `bake_actions` would + // debug_assert / panic. This verifies registration happened. + timeline.bake_actions(®istry, &world); + timeline.set_target_time(1.0); + timeline.queue_actions(); + timeline.sample_queued_actions(®istry, &mut world); + + assert!((world.0[0] - 5.0).abs() < f32::EPSILON); + } + + #[test] + fn timeline_builder_act_step_produces_step_interpolation() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + + let frag = + builder.act_step(0_usize, path!(), |x| x + 10.0).play(1.0); + let track = frag.compile(); + builder.add_tracks(track); + let mut timeline = builder.compile(); + + let mut world = VecWorld(alloc::vec![0.0]); + timeline.bake_actions(®istry, &world); + + // At t=0.5 step-interpolation should stay at 0.0 (start value). + timeline.set_target_time(0.5); + timeline.queue_actions(); + timeline.sample_queued_actions(®istry, &mut world); + + // Step interp returns start until t == 1.0. + assert_eq!(world.0[0], 0.0); + + // At t=1.0 step-interpolation should jump to the end value. + timeline.set_target_time(1.0); + timeline.queue_actions(); + timeline.sample_queued_actions(®istry, &mut world); + assert_eq!(world.0[0], 10.0); + } + + // ── Timeline (core bake / sample cycle) ─────────────────────────────────── + + #[test] + fn timeline_bake_and_sample_at_midpoint() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + + let frag = builder + .act(0_usize, path!(), |x| x + 10.0) + .with_interp(linear_f32) + .play(1.0); + + let track = frag.compile(); + builder.add_tracks(track); + let mut timeline = builder.compile(); + + let mut world = VecWorld(alloc::vec![0.0]); + + // Bake once before sampling. + timeline.bake_actions(®istry, &world); + + timeline.set_target_time(0.5); + timeline.queue_actions(); + timeline.sample_queued_actions(®istry, &mut world); + + let expected = 5.0_f32; + assert!( + (world.0[0] - expected).abs() < f32::EPSILON, + "At t=0.5 expected {expected}, got {}", + world.0[0] + ); + } + + #[test] + fn timeline_bake_and_sample_at_end() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + + let frag = builder + .act(0_usize, path!(), |x| x + 10.0) + .with_interp(linear_f32) + .play(1.0); + + builder.add_tracks(frag.compile()); + let mut timeline = builder.compile(); + let mut world = VecWorld(alloc::vec![0.0]); + + timeline.bake_actions(®istry, &world); + + timeline.set_target_time(1.0); + timeline.queue_actions(); + timeline.sample_queued_actions(®istry, &mut world); + + assert!( + (world.0[0] - 10.0).abs() < f32::EPSILON, + "At t=1.0 expected 10.0, got {}", + world.0[0] + ); + } + + #[test] + fn timeline_bake_and_sample_at_start() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + + let frag = builder + .act(0_usize, path!(), |x| x + 10.0) + .with_interp(linear_f32) + .play(1.0); + + builder.add_tracks(frag.compile()); + let mut timeline = builder.compile(); + let mut world = VecWorld(alloc::vec![0.0]); + + timeline.bake_actions(®istry, &world); + + // Sampling at t=0.0 should produce the start value. + timeline.set_target_time(0.0); + timeline.queue_actions(); + timeline.sample_queued_actions(®istry, &mut world); + + assert_eq!(world.0[0], 0.0); + } + + // ── Timeline setters ───────────────────────────────────────────────────── + + #[test] + fn set_target_time_clamps_below_zero() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + let frag = builder + .act(0_usize, path!(), |x| x + 1.0) + .with_interp(linear_f32) + .play(2.0); + builder.add_tracks(frag.compile()); + let mut timeline = builder.compile(); + + timeline.set_target_time(-5.0); + assert_eq!(timeline.target_time(), 0.0); + } + + #[test] + fn set_target_time_clamps_above_duration() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + let frag = builder + .act(0_usize, path!(), |x| x + 1.0) + .with_interp(linear_f32) + .play(2.0); + builder.add_tracks(frag.compile()); + let mut timeline = builder.compile(); + + timeline.set_target_time(999.0); + assert_eq!(timeline.target_time(), 2.0); + } + + #[test] + fn set_target_track_clamps_above_last_index() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + let frag = builder + .act(0_usize, path!(), |x| x + 1.0) + .with_interp(linear_f32) + .play(1.0); + builder.add_tracks(frag.compile()); + let mut timeline = builder.compile(); + + timeline.set_target_track(999); + assert_eq!(timeline.target_index(), 0); // only one track at index 0 + } + + // ── Timeline completion detection ───────────────────────────────────────── + + #[test] + fn timeline_is_not_complete_at_start() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + let frag = builder + .act(0_usize, path!(), |x| x + 1.0) + .with_interp(linear_f32) + .play(1.0); + builder.add_tracks(frag.compile()); + let mut timeline = builder.compile(); + + assert!(!timeline.is_complete()); + } + + #[test] + fn timeline_is_complete_at_end_of_last_track() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + let frag = builder + .act(0_usize, path!(), |x| x + 1.0) + .with_interp(linear_f32) + .play(1.0); + builder.add_tracks(frag.compile()); + let mut timeline = builder.compile(); + let mut world = VecWorld(alloc::vec![0.0]); + + timeline.bake_actions(®istry, &world); + timeline.set_target_time(1.0); + timeline.queue_actions(); + timeline.sample_queued_actions(®istry, &mut world); + + assert!(timeline.is_complete()); + } + + #[test] + fn timeline_is_last_track_with_single_track() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + let frag = builder + .act(0_usize, path!(), |x| x + 1.0) + .with_interp(linear_f32) + .play(1.0); + builder.add_tracks(frag.compile()); + let timeline = builder.compile(); + + assert!(timeline.is_last_track()); + } + + // ── QueueCache ──────────────────────────────────────────────────────────── + + #[test] + fn queue_cache_starts_empty() { + let cache = QueueCache::new(); + assert!(cache.is_empty()); + } + + #[test] + fn queue_cache_clears_on_demand() { + // Indirectly test clear via the timeline: after queue_actions the + // cache is populated, but after a second call it should be refreshed. + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + let frag = builder + .act(0_usize, path!(), |x| x + 1.0) + .with_interp(linear_f32) + .play(1.0); + builder.add_tracks(frag.compile()); + let mut timeline = builder.compile(); + let mut world = VecWorld(alloc::vec![0.0]); + + timeline.bake_actions(®istry, &world); + timeline.set_target_time(0.5); + timeline.queue_actions(); + // After queue, cache should be non-empty. + assert!(!timeline.queue_cache().is_empty()); + + // Calling queue again resets the cache before re-populating. + timeline.queue_actions(); + assert!(!timeline.queue_cache().is_empty()); + } + + // ── Multi-subject animation ─────────────────────────────────────────────── + + #[test] + fn timeline_animates_multiple_subjects_independently() { + let mut registry = Registry::new(); + let mut builder = registry.create_builder::(); + + // Both subjects animated concurrently in a single track using ord_all. + use crate::track::TrackOrdering; + let frag0 = builder + .act(0_usize, path!(), |x| x + 10.0) + .with_interp(linear_f32) + .play(1.0); + let frag1 = builder + .act(1_usize, path!(), |x| x + 20.0) + .with_interp(linear_f32) + .play(1.0); + + let track = [frag0, frag1].ord_all().compile(); + + builder.add_tracks(track); + let mut timeline = builder.compile(); + let mut world = VecWorld(alloc::vec![0.0, 0.0]); + + timeline.bake_actions(®istry, &world); + timeline.set_target_time(1.0); + timeline.queue_actions(); + timeline.sample_queued_actions(®istry, &mut world); + + assert!( + (world.0[0] - 10.0).abs() < f32::EPSILON, + "Subject 0 expected 10.0, got {}", + world.0[0] + ); + assert!( + (world.0[1] - 20.0).abs() < f32::EPSILON, + "Subject 1 expected 20.0, got {}", + world.0[1] + ); + } +}