animate.rs
8.8 kB · rust · 287 lines
1use super::paths::strokes;2use super::raster::{layout, Block, Layout};3use std::collections::BTreeSet;45/// The playback rate of every animation, in frames per second.6pub const FPS: usize = 25;78/// The default number of frames a cycle rests between movements.9pub const HOLD: usize = 25;1011/// A frame-by-frame animation over a fixed board.12#[derive(Clone, Debug, PartialEq, Eq)]13pub struct Anim {14 /// The board height in cells.15 pub rows: usize,16 /// The board width in cells.17 pub cols: usize,18 /// The playback rate in frames per second.19 pub fps: usize,20 /// The frames, each a sorted list of lit row-major cell indices.21 pub frames: Vec<Vec<usize>>,22}2324/// Writes the text in stroke order, one cell per frame, from an empty padded board to the full raster.25///26/// ```27/// let write = mrlyfont::animate("MRLYPROD", 1);28/// assert_eq!((write.rows, write.cols, write.fps), (7, 49, 25));29/// assert!(write.frames[0].is_empty());30/// ```31pub fn animate(text: &str, pad: usize) -> Anim {32 let laid = layout(text);33 let (rows, cols) = board(&laid, pad);34 let mut frames = vec![Vec::new()];35 let mut current: Vec<usize> = Vec::new();36 for block in &laid.blocks {37 for (r, c) in strokes(block.char).into_iter().flatten() {38 current.push((pad + block.offset + r) * cols + (pad + block.col + c));39 let mut frame = current.clone();40 frame.sort_unstable();41 frames.push(frame);42 }43 }44 Anim {45 rows,46 cols,47 fps: FPS,48 frames,49 }50}5152/// Folds the written text's glyphs, frame by frame, into one centered stack.53pub fn merge(text: &str, pad: usize) -> Vec<Vec<usize>> {54 let laid = layout(text);55 let (rows, cols) = board(&laid, pad);56 let n = laid.blocks.len();57 let phases = n / 2;58 let starts: Vec<(i64, i64)> = laid59 .blocks60 .iter()61 .map(|b| ((pad + b.col) as i64, (pad + b.offset) as i64))62 .collect();63 let targets: Vec<(i64, i64)> = laid64 .blocks65 .iter()66 .map(|b| (((cols - b.width()) / 2) as i64, (pad + b.offset) as i64))67 .collect();68 if phases == 0 {69 return vec![stamp(&laid.blocks, &starts, rows, cols)];70 }71 let total = cols / 2;72 let mut frames: Vec<Vec<usize>> = Vec::new();73 let mut prev: Option<Vec<usize>> = None;74 for i in 0..=total {75 let (phase, progress) = beat(i, total, phases);76 let spots: Vec<(i64, i64)> = (0..n)77 .map(|idx| place(idx, n, phases, phase, progress, &starts, &targets))78 .collect();79 let frame = stamp(&laid.blocks, &spots, rows, cols);80 if prev.as_ref() != Some(&frame) {81 frames.push(frame.clone());82 prev = Some(frame);83 }84 }85 frames86}8788/// Chains the write, the merge and their reversals into one loop, resting hold frames after each.89pub fn cycle(write: &Anim, merge: &[Vec<usize>], hold: usize) -> Anim {90 let mut frames: Vec<Vec<usize>> = Vec::new();91 let rest = |frame: &Vec<usize>, out: &mut Vec<Vec<usize>>| {92 for _ in 0..hold {93 out.push(frame.clone());94 }95 };96 frames.extend(write.frames.iter().cloned());97 rest(write.frames.last().unwrap(), &mut frames);98 frames.extend(merge.iter().cloned());99 rest(merge.last().unwrap(), &mut frames);100 frames.extend(merge.iter().rev().cloned());101 rest(merge.first().unwrap(), &mut frames);102 frames.extend(write.frames.iter().rev().cloned());103 rest(write.frames.first().unwrap(), &mut frames);104 Anim {105 rows: write.rows,106 cols: write.cols,107 fps: write.fps,108 frames,109 }110}111112fn board(laid: &Layout, pad: usize) -> (usize, usize) {113 if laid.blocks.is_empty() {114 return (0, 0);115 }116 (laid.height + 2 * pad, laid.width + 2 * pad)117}118119fn stamp(blocks: &[Block], spots: &[(i64, i64)], rows: usize, cols: usize) -> Vec<usize> {120 let mut active: BTreeSet<usize> = BTreeSet::new();121 for (block, &(cx, cy)) in blocks.iter().zip(spots) {122 for (r, row) in block.rows.iter().enumerate() {123 for (c, ch) in row.chars().enumerate() {124 if ch != '1' {125 continue;126 }127 let (y, x) = (cy + r as i64, cx + c as i64);128 if y >= 0 && y < rows as i64 && x >= 0 && x < cols as i64 {129 active.insert(y as usize * cols + x as usize);130 }131 }132 }133 }134 active.into_iter().collect()135}136137fn beat(frame: usize, total: usize, phases: usize) -> (usize, f64) {138 let len = total / phases;139 for p in 1..phases {140 if frame < len * p {141 return (p, (frame - len * (p - 1)) as f64 / len as f64);142 }143 }144 let done = len * (phases - 1);145 (phases, (frame - done) as f64 / (total - done) as f64)146}147148fn lerp(start: i64, end: i64, p: f64) -> i64 {149 start + ((end - start) as f64 * p).trunc() as i64150}151152fn place(153 idx: usize,154 n: usize,155 phases: usize,156 phase: usize,157 progress: f64,158 starts: &[(i64, i64)],159 targets: &[(i64, i64)],160) -> (i64, i64) {161 let slide = |a: (i64, i64), b: (i64, i64)| (lerp(a.0, b.0, progress), lerp(a.1, b.1, progress));162 if phase < phases {163 if idx < phase {164 return slide(starts[phase - 1], starts[phase]);165 }166 if idx >= n - phase {167 return slide(starts[n - phase], starts[n - phase - 1]);168 }169 return starts[idx];170 }171 let anchor = if idx < phases {172 starts[phases - 1]173 } else if idx >= n - phases {174 starts[n - phases]175 } else {176 starts[idx]177 };178 slide(anchor, targets[idx])179}180181#[cfg(test)]182mod tests {183 use super::*;184 use crate::raster;185 const WORDMARK: &str = "MRLYPROD";186187 fn lit(rows: &[Vec<u8>]) -> usize {188 rows.iter().flatten().filter(|&&v| v == 1).count()189 }190191 #[test]192 fn the_wordmark_board_is_seven_by_forty_nine() {193 let write = animate(WORDMARK, 1);194 assert_eq!((write.rows, write.cols, write.fps), (7, 49, 25));195 }196197 #[test]198 fn writing_starts_empty_and_grows_one_cell_a_frame() {199 let write = animate(WORDMARK, 1);200 assert!(write.frames[0].is_empty());201 for pair in write.frames.windows(2) {202 assert_eq!(pair[1].len(), pair[0].len() + 1);203 }204 assert_eq!(write.frames.len(), 104);205 assert_eq!(206 write.frames.last().unwrap().len(),207 lit(&raster(WORDMARK)),208 "the last frame is the whole wordmark"209 );210 }211212 #[test]213 fn the_last_frame_is_the_padded_raster() {214 let write = animate(WORDMARK, 1);215 let grid = raster(WORDMARK);216 let want: Vec<usize> = grid217 .iter()218 .enumerate()219 .flat_map(|(r, row)| {220 row.iter()221 .enumerate()222 .filter(|&(_, &v)| v == 1)223 .map(move |(c, _)| (1 + r) * 49 + 1 + c)224 })225 .collect();226 assert_eq!(*write.frames.last().unwrap(), want);227 }228229 #[test]230 fn merging_collapses_in_twenty_two_frames() {231 let merged = merge(WORDMARK, 1);232 assert_eq!(merged.len(), 22);233 assert_eq!(merged[0], animate(WORDMARK, 1).frames[103]);234 let x = crate::glyph('X').unwrap();235 let stacked: Vec<usize> = x236 .rows237 .iter()238 .enumerate()239 .flat_map(|(r, row)| {240 row.chars()241 .enumerate()242 .filter(|&(_, ch)| ch == '1')243 .map(move |(c, _)| (1 + r) * 49 + 22 + c)244 })245 .collect();246 assert_eq!(*merged.last().unwrap(), stacked, "the eight fold into X");247 }248249 #[test]250 fn frames_are_sorted_and_in_bounds() {251 let write = animate(WORDMARK, 1);252 let anim = cycle(&write, &merge(WORDMARK, 1), HOLD);253 for frame in &anim.frames {254 assert!(frame.windows(2).all(|w| w[0] < w[1]));255 assert!(frame.iter().all(|&i| i < anim.rows * anim.cols));256 }257 }258259 #[test]260 fn the_cycle_loops_through_both_halves() {261 let write = animate(WORDMARK, 1);262 let merged = merge(WORDMARK, 1);263 let anim = cycle(&write, &merged, HOLD);264 assert_eq!(265 anim.frames.len(),266 2 * write.frames.len() + 2 * merged.len() + 4 * HOLD267 );268 assert_eq!(anim.frames.len(), 352);269 }270271 #[test]272 fn any_string_writes_itself() {273 for text in ["a", "hi", "mrly.net", "(1)"] {274 let write = animate(text, 2);275 let grid = raster(text);276 assert_eq!(write.rows, grid.len() + 4);277 assert_eq!(write.cols, grid[0].len() + 4);278 assert_eq!(write.frames.last().unwrap().len(), lit(&grid));279 assert_eq!(write.frames.len(), lit(&grid) + 1);280 }281 }282283 #[test]284 fn a_lone_glyph_has_nothing_to_merge() {285 assert_eq!(merge("A", 1).len(), 1);286 }287}