///|
/// Playback behavior for a procedural animation timeline.
pub(all) enum TimelineMode {
Clamp
Loop
PingPong
Continue
} derive(Eq, Debug, ToJson)
///|
/// A normalized time range used by clips and tracks.
pub(all) struct TimelineRange {
start : Double
end : Double
} derive(Eq, Debug, ToJson)
///|
pub fn TimelineRange::new(start : Double, end : Double) -> TimelineRange {
{ start, end: end.max(start) }
}
///|
pub fn TimelineRange::duration(self : TimelineRange) -> Double {
self.end - self.start
}
///|
pub fn TimelineRange::at(self : TimelineRange, progress : Double) -> Double {
self.start + self.duration() * progress.max(0.0).min(1.0)
}
///|
/// A scalar animation track with sorted keyframes.
pub(all) struct ScalarTrack {
name : String
frames : Array[Keyframe[Double]]
easing : Easing
} derive(Debug)
///|
pub fn ScalarTrack::new(
name : String,
easing? : Easing = Smooth,
) -> ScalarTrack {
{ name, frames: [], easing }
}
///|
pub fn ScalarTrack::add(
self : ScalarTrack,
time : Double,
value : Double,
) -> ScalarTrack {
self.frames.push(keyframe(time.max(0.0), value))
sort_scalar_frames(self.frames)
self
}
///|
fn sort_scalar_frames(frames : Array[Keyframe[Double]]) -> Unit {
for i in 0.. Double {
if self.frames.length() == 0 {
0.0
} else {
self.frames[self.frames.length() - 1].time
}
}
///|
pub fn ScalarTrack::sample(self : ScalarTrack, time : Double) -> Double {
frames_for_double(self.frames, time).unwrap_or(0.0)
}
///|
pub fn ScalarTrack::sample_eased(self : ScalarTrack, time : Double) -> Double {
if self.frames.length() < 2 {
self.sample(time)
} else if time <= self.frames[0].time {
self.frames[0].value
} else if time >= self.duration() {
self.frames[self.frames.length() - 1].value
} else {
let mut result = self.frames[0].value
for index in 0..<(self.frames.length() - 1) {
let left = self.frames[index]
let right = self.frames[index + 1]
if time >= left.time && time <= right.time {
let amount = (time - left.time) / (right.time - left.time).max(0.000001)
result = animate_double(
left.value,
right.value,
ease(self.easing, amount),
)
}
}
result
}
}
///|
pub fn ScalarTrack::map(
self : ScalarTrack,
transform : (Double) -> Double,
) -> ScalarTrack {
let result = ScalarTrack::new(self.name, easing=self.easing)
for frame in self.frames {
let _ = result.add(frame.time, transform(frame.value))
}
result
}
///|
pub fn ScalarTrack::scale(self : ScalarTrack, factor : Double) -> ScalarTrack {
self.map(fn(value) { value * factor })
}
///|
pub fn ScalarTrack::offset(self : ScalarTrack, amount : Double) -> ScalarTrack {
self.map(fn(value) { value + amount })
}
///|
/// A vector animation track for moving and deforming motifs.
pub(all) struct Vec2Track {
name : String
frames : Array[Keyframe[Vec2]]
easing : Easing
} derive(Debug)
///|
pub fn Vec2Track::new(name : String, easing? : Easing = Smooth) -> Vec2Track {
{ name, frames: [], easing }
}
///|
pub fn Vec2Track::add(
self : Vec2Track,
time : Double,
value : Vec2,
) -> Vec2Track {
self.frames.push(keyframe(time.max(0.0), value))
sort_vec2_frames(self.frames)
self
}
///|
fn sort_vec2_frames(frames : Array[Keyframe[Vec2]]) -> Unit {
for i in 0.. Double {
if self.frames.length() == 0 {
0.0
} else {
self.frames[self.frames.length() - 1].time
}
}
///|
pub fn Vec2Track::sample(self : Vec2Track, time : Double) -> Vec2 {
if self.frames.length() == 0 {
Vec2::zero()
} else if time <= self.frames[0].time {
self.frames[0].value
} else if time >= self.duration() {
self.frames[self.frames.length() - 1].value
} else {
let mut result = self.frames[0].value
for index in 0..<(self.frames.length() - 1) {
let left = self.frames[index]
let right = self.frames[index + 1]
if time >= left.time && time <= right.time {
let amount = (time - left.time) / (right.time - left.time).max(0.000001)
result = animate_vec2(
left.value,
right.value,
ease(self.easing, amount),
)
}
}
result
}
}
///|
pub fn Vec2Track::map(
self : Vec2Track,
transform : (Vec2) -> Vec2,
) -> Vec2Track {
let result = Vec2Track::new(self.name, easing=self.easing)
for frame in self.frames {
let _ = result.add(frame.time, transform(frame.value))
}
result
}
///|
pub fn Vec2Track::translate(self : Vec2Track, offset : Vec2) -> Vec2Track {
self.map(fn(value) { value.add(offset) })
}
///|
/// A color animation track for palette and lighting changes.
pub(all) struct ColorTrack {
name : String
frames : Array[Keyframe[Color]]
easing : Easing
} derive(Debug)
///|
pub fn ColorTrack::new(name : String, easing? : Easing = Smooth) -> ColorTrack {
{ name, frames: [], easing }
}
///|
pub fn ColorTrack::add(
self : ColorTrack,
time : Double,
value : Color,
) -> ColorTrack {
self.frames.push(keyframe(time.max(0.0), value))
sort_color_frames(self.frames)
self
}
///|
fn sort_color_frames(frames : Array[Keyframe[Color]]) -> Unit {
for i in 0.. Double {
if self.frames.length() == 0 {
0.0
} else {
self.frames[self.frames.length() - 1].time
}
}
///|
pub fn ColorTrack::sample(self : ColorTrack, time : Double) -> Color {
if self.frames.length() == 0 {
Color::rgba(0.0, 0.0, 0.0, a=0.0)
} else if time <= self.frames[0].time {
self.frames[0].value
} else if time >= self.duration() {
self.frames[self.frames.length() - 1].value
} else {
let mut result = self.frames[0].value
for index in 0..<(self.frames.length() - 1) {
let left = self.frames[index]
let right = self.frames[index + 1]
if time >= left.time && time <= right.time {
let amount = (time - left.time) / (right.time - left.time).max(0.000001)
result = animate_color(
left.value,
right.value,
ease(self.easing, amount),
)
}
}
result
}
}
///|
pub fn ColorTrack::map(
self : ColorTrack,
transform : (Color) -> Color,
) -> ColorTrack {
let result = ColorTrack::new(self.name, easing=self.easing)
for frame in self.frames {
let _ = result.add(frame.time, transform(frame.value))
}
result
}
///|
pub fn ColorTrack::with_alpha(self : ColorTrack, alpha : Double) -> ColorTrack {
self.map(fn(color) { color.with_alpha(alpha) })
}
///|
/// A named event marker in a timeline.
pub(all) struct TimelineMarker {
time : Double
name : String
payload : String
} derive(Eq, Debug, ToJson)
///|
/// A complete set of parameter tracks with playback settings.
pub(all) struct Timeline {
range : TimelineRange
mode : TimelineMode
scalar_tracks : Array[ScalarTrack]
vector_tracks : Array[Vec2Track]
color_tracks : Array[ColorTrack]
markers : Array[TimelineMarker]
} derive(Debug)
///|
pub fn Timeline::new(duration : Double) -> Timeline {
{
range: TimelineRange::new(0.0, duration.max(0.0)),
mode: Clamp,
scalar_tracks: [],
vector_tracks: [],
color_tracks: [],
markers: [],
}
}
///|
pub fn Timeline::with_range(self : Timeline, range : TimelineRange) -> Timeline {
{ ..self, range, }
}
///|
pub fn Timeline::with_mode(self : Timeline, mode : TimelineMode) -> Timeline {
{ ..self, mode, }
}
///|
pub fn Timeline::add_scalar(self : Timeline, track : ScalarTrack) -> Timeline {
self.scalar_tracks.push(track)
self
}
///|
pub fn Timeline::add_vector(self : Timeline, track : Vec2Track) -> Timeline {
self.vector_tracks.push(track)
self
}
///|
pub fn Timeline::add_color(self : Timeline, track : ColorTrack) -> Timeline {
self.color_tracks.push(track)
self
}
///|
pub fn Timeline::marker(
self : Timeline,
time : Double,
name : String,
payload : String,
) -> Timeline {
self.markers.push({ time: time.max(0.0), name, payload })
self
}
///|
pub fn Timeline::duration(self : Timeline) -> Double {
let mut duration = self.range.duration()
for track in self.scalar_tracks {
duration = duration.max(track.duration())
}
for track in self.vector_tracks {
duration = duration.max(track.duration())
}
for track in self.color_tracks {
duration = duration.max(track.duration())
}
duration
}
///|
fn timeline_time(timeline : Timeline, time : Double) -> Double {
let duration = timeline.duration()
if duration <= 0.0 {
0.0
} else {
let relative = time - timeline.range.start
match timeline.mode {
Clamp => relative.max(0.0).min(duration)
Continue => relative.max(0.0)
Loop => {
let wrapped = relative % duration
if wrapped < 0.0 {
wrapped + duration
} else {
wrapped
}
}
PingPong => {
let cycle = duration * 2.0
let wrapped = relative % cycle
let positive = if wrapped < 0.0 { wrapped + cycle } else { wrapped }
if positive <= duration {
positive
} else {
cycle - positive
}
}
}
}
}
///|
pub fn Timeline::sample_scalar(
self : Timeline,
name : String,
time : Double,
) -> Double? {
let local_time = timeline_time(self, time)
for track in self.scalar_tracks {
if track.name == name {
return Some(track.sample_eased(local_time))
}
}
None
}
///|
pub fn Timeline::sample_vector(
self : Timeline,
name : String,
time : Double,
) -> Vec2? {
let local_time = timeline_time(self, time)
for track in self.vector_tracks {
if track.name == name {
return Some(track.sample(local_time))
}
}
None
}
///|
pub fn Timeline::sample_color(
self : Timeline,
name : String,
time : Double,
) -> Color? {
let local_time = timeline_time(self, time)
for track in self.color_tracks {
if track.name == name {
return Some(track.sample(local_time))
}
}
None
}
///|
pub fn Timeline::markers_at(
self : Timeline,
time : Double,
tolerance : Double,
) -> Array[TimelineMarker] {
let result = []
for marker in self.markers {
if (marker.time - timeline_time(self, time)).abs() <= tolerance.max(0.0) {
result.push(marker)
}
}
result
}
///|
pub fn Timeline::sample_times(
self : Timeline,
frames : Int,
include_end : Bool,
) -> Array[Double] {
let result = []
if frames > 0 {
let count = if include_end { frames } else { frames.max(1) }
for index in 0.. TimelineFrame {
let scalars = []
let vectors = []
let colors = []
for track in self.scalar_tracks {
scalars.push((track.name, track.sample_eased(timeline_time(self, time))))
}
for track in self.vector_tracks {
vectors.push((track.name, track.sample(timeline_time(self, time))))
}
for track in self.color_tracks {
colors.push((track.name, track.sample(timeline_time(self, time))))
}
{ time, scalars, vectors, colors }
}
///|
pub fn Timeline::frames(self : Timeline, count : Int) -> Array[TimelineFrame] {
let result = []
for time in self.sample_times(count, true) {
result.push(self.frame(time))
}
result
}
///|
/// Interpolates a motif's placement from timeline tracks.
pub fn Motif::animate_motif(
self : Motif,
timeline : Timeline,
time : Double,
) -> Motif {
let position = timeline.sample_vector("position", time).unwrap_or(self.anchor)
let scale = timeline.sample_scalar("scale", time).unwrap_or(self.scale)
let rotation = timeline
.sample_scalar("rotation", time)
.unwrap_or(self.rotation)
let color = timeline
.sample_color("color", time)
.unwrap_or(Color::rgba(0.0, 0.0, 0.0, a=0.0))
let style = if color.a == 0.0 {
self.style
} else {
{ ..self.style, fill: color.to_hex() }
}
{ ..self, style, anchor: position, scale, rotation }
}
///|
/// Generates an array of animated motif states at sampled times.
pub fn animate_motif_frames(
motif : Motif,
timeline : Timeline,
frame_count : Int,
) -> Array[Motif] {
let result = []
for time in timeline.sample_times(frame_count, true) {
result.push(motif.animate_motif(timeline, time))
}
result
}
///|
/// Returns an SVG document for one animated motif state.
pub fn animated_motif_document(
motif : Motif,
timeline : Timeline,
time : Double,
width : Double,
height : Double,
background : Color,
) -> SvgDocument {
let document = SvgDocument::new(width, height)
document.add(
SvgElement::SvgPath(
Path::rect(width, height),
SvgStyle::new(fill=background.to_hex(), stroke="none"),
),
)
let state = motif.animate_motif(timeline, time)
document.add(SvgElement::SvgPath(state.transformed_path(), state.style))
document
}
///|
/// Builds a timeline from a position and scale animation recipe.
pub fn motif_timeline(
duration : Double,
start : Vec2,
end : Vec2,
start_scale : Double,
end_scale : Double,
) -> Timeline {
Timeline::new(duration)
.add_vector(Vec2Track::new("position").add(0.0, start).add(duration, end))
.add_scalar(
ScalarTrack::new("scale").add(0.0, start_scale).add(duration, end_scale),
)
}
///|
/// Serializes timeline markers for debugging and build reports.
pub fn timeline_markers_csv(timeline : Timeline) -> String {
let builder = StringBuilder()
builder.write_string("time,name,payload\n")
for marker in timeline.markers {
builder.write_string("\{marker.time},\{marker.name},\{marker.payload}\n")
}
builder.to_string()
}
///|
/// Returns a deterministic summary of tracks and playback duration.
pub fn timeline_report(timeline : Timeline) -> String {
"duration=\{timeline.duration()},scalars=\{timeline.scalar_tracks.length()},vectors=\{timeline.vector_tracks.length()},colors=\{timeline.color_tracks.length()},markers=\{timeline.markers.length()}"
}