diff --git a/crates/anim/src/effects.rs b/crates/anim/src/effects.rs index 51a82a71..e51052d9 100644 --- a/crates/anim/src/effects.rs +++ b/crates/anim/src/effects.rs @@ -78,6 +78,7 @@ pub(crate) fn emphasis_persists(effect: &str) -> bool { fn uses_default_smoothing(effect: &str) -> bool { matches!(effect, "fly" | "float" | "rise" | "peek" | "lines" | "arcs" | "turns" | "shapes" | "loops" | "customPath") + || deckcraft_model::anim::preset_path(effect).is_some() } impl EffectSpec { diff --git a/crates/anim/src/path.rs b/crates/anim/src/path.rs index ffe4f5e6..af61bd58 100644 --- a/crates/anim/src/path.rs +++ b/crates/anim/src/path.rs @@ -288,6 +288,27 @@ pub fn default_path(effect: &str, option: &str) -> String { } }, "loops" => "M 0 0 C 0.08 -0.1 0.16 0 0.08 0.05 C 0 0.1 -0.08 0.1 -0.08 0 C -0.08 -0.1 0 -0.05 0 0 E".into(), - _ => String::new(), + other => deckcraft_model::anim::preset_path(other).unwrap_or_default(), + } +} + +#[cfg(test)] +mod preset_path_tests { + use super::*; + + #[test] + fn every_extra_motion_path_preset_starts_at_its_shape_and_moves() { + use deckcraft_model::anim::{ANIMATIONS, AnimClass, preset_path}; + let extra: Vec<_> = ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path && preset_path(a.0).is_some()).collect(); + assert!(extra.len() >= 30); + for a in extra { + assert_eq!(default_path(a.0, ""), preset_path(a.0).unwrap_or_default()); + let mp = MotionPath::parse(&default_path(a.0, "")); + let (x, y) = mp.at(0.0); + assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "{} starts at its shape", a.0); + let far = mp.points.iter().map(|p| p.0.abs() + p.1.abs()).fold(0.0, f64::max); + assert!(far > 0.05, "{} moves", a.0); + assert!(mp.points.iter().all(|p| p.0.abs() < 0.5 && p.1.abs() < 0.5), "{} stays near its shape", a.0); + } } } diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 5804fee6..e6771a5d 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -127,7 +127,7 @@ pub fn default_path(effect: &str, option: &str) -> String { "turns" => format!("M 0 0 L {} {} C {} {} {dx} {} {dx} {dy} E", dx * 0.5, dy * 0.5, dx, dy * 0.5, dy * 0.75), "shapes" => "M 0 0 C 0.1 0 0.1 0.15 0 0.15 C -0.1 0.15 -0.1 0 0 0 Z E".into(), "loops" => "M 0 0 C 0.1 -0.1 0.2 0.1 0.1 0.1 C 0 0.1 0.1 -0.1 0.25 0 E".into(), - _ => format!("M 0 0 L {dx} {dy} E"), + other => deckcraft_model::anim::preset_path(other).unwrap_or_else(|| format!("M 0 0 L {dx} {dy} E")), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d23792c3..0ded4593 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1523,3 +1523,26 @@ fn damaged_native_file_opens_as_repaired_copy() { assert_eq!(std::fs::read(dir.join("bad.deckcraft")).unwrap(), bad, "original untouched"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn motion_path_presets_have_paths_ids_and_round_trip() { + use deckcraft_model::anim::{ANIMATIONS, AnimClass, preset_path}; + let paths: Vec<_> = ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path).collect(); + // Preset ids identify the effect when PowerPoint's file is read back: no two share one. + let mut ids: Vec = paths.iter().map(|a| a.3).collect(); + ids.sort_unstable(); + let n = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate motion-path preset ids"); + assert!(n >= 40, "{n} motion paths"); + let mut s = session(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 50, 50]})).unwrap()["id"].clone(); + s.execute("animation.set", &json!({"ids": [id], "effect": "heart", "class": "path"})).unwrap(); + let a = s.doc().unwrap().doc.slides[s.doc().unwrap().selection.slide].animations[0].clone(); + assert_eq!(a.path, preset_path("heart")); + let p = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).unwrap()).unwrap(); + let b = back.slides.iter().flat_map(|sl| &sl.animations).next().unwrap(); + assert_eq!((b.effect.as_str(), b.class), ("heart", AnimClass::Path)); + assert!(b.path.as_deref().is_some_and(|p| p.contains('L'))); +} diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs index 4fec41c0..b34fb412 100644 --- a/crates/model/src/anim.rs +++ b/crates/model/src/anim.rs @@ -394,6 +394,40 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ &["circle", "diamond", "hexagon", "triangle", "square", "trapezoid", "octagon", "parallelogram", "pentagon"], ), ("loops", "Loops", AnimClass::Path, 61, 2000, &[]), + ("diagonalDownRight", "Diagonal Down Right", AnimClass::Path, 49, 2000, &[]), + ("diagonalUpRight", "Diagonal Up Right", AnimClass::Path, 56, 2000, &[]), + ("zigzag", "Zigzag", AnimClass::Path, 38, 2000, &[]), + ("sineWave", "Sine Wave", AnimClass::Path, 40, 2000, &[]), + ("pathWave", "Wave", AnimClass::Path, 47, 2000, &[]), + ("decayingWave", "Decaying Wave", AnimClass::Path, 60, 2000, &[]), + ("heartbeat", "Heartbeat", AnimClass::Path, 45, 2000, &[]), + ("sCurve1", "S Curve 1", AnimClass::Path, 59, 2000, &[]), + ("sCurve2", "S Curve 2", AnimClass::Path, 39, 2000, &[]), + ("stairsDown", "Stairs Down", AnimClass::Path, 62, 2000, &[]), + ("bounceRight", "Bounce Right", AnimClass::Path, 54, 2000, &[]), + ("bounceLeft", "Bounce Left", AnimClass::Path, 41, 2000, &[]), + ("spiralRight", "Spiral Right", AnimClass::Path, 46, 2000, &[]), + ("spiralLeft", "Spiral Left", AnimClass::Path, 55, 2000, &[]), + ("spring", "Spring", AnimClass::Path, 53, 2000, &[]), + ("funnel", "Funnel", AnimClass::Path, 52, 2000, &[]), + ("swoosh", "Swoosh", AnimClass::Path, 30, 2000, &[]), + ("star4", "4 Point Star", AnimClass::Path, 16, 2000, &[]), + ("star5", "5 Point Star", AnimClass::Path, 5, 2000, &[]), + ("star6", "6 Point Star", AnimClass::Path, 11, 2000, &[]), + ("star8", "8 Point Star", AnimClass::Path, 17, 2000, &[]), + ("heart", "Heart", AnimClass::Path, 9, 2000, &[]), + ("crescentMoon", "Crescent Moon", AnimClass::Path, 6, 2000, &[]), + ("teardrop", "Teardrop", AnimClass::Path, 18, 2000, &[]), + ("football", "Football", AnimClass::Path, 12, 2000, &[]), + ("plusPath", "Plus", AnimClass::Path, 32, 2000, &[]), + ("rightTriangle", "Right Triangle", AnimClass::Path, 2, 2000, &[]), + ("curvedSquare", "Curved Square", AnimClass::Path, 20, 2000, &[]), + ("figure8Horizontal", "Horizontal Figure 8", AnimClass::Path, 26, 2000, &[]), + ("figure8Vertical", "Vertical Figure 8", AnimClass::Path, 22, 2000, &[]), + ("loopDeLoop", "Loop de Loop", AnimClass::Path, 24, 2000, &[]), + ("bean", "Bean", AnimClass::Path, 31, 2000, &[]), + ("peanut", "Peanut", AnimClass::Path, 27, 2000, &[]), + ("neutron", "Neutron", AnimClass::Path, 29, 2000, &[]), ("customPath", "Custom Path", AnimClass::Path, 0, 2000, &[]), ("play", "Play", AnimClass::Media, 1, 0, &[]), ("pause", "Pause", AnimClass::Media, 2, 0, &[]), @@ -404,6 +438,149 @@ pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static (&'stat ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect)) } +/// The path of a motion-path preset beyond Lines, Arcs, Turns, Shapes and Loops, in slide +/// fractions from the shape's position (our path syntax: `M`, `L`, `Z`, ending in `E`). +/// `None` for an effect that isn't one of these presets. +pub fn preset_path(effect: &str) -> Option { + use std::f64::consts::{PI, TAU}; + use std::fmt::Write; + // Sample `f` over 0..=1 into a polyline starting at (0, 0). + fn poly(n: usize, closed: bool, f: impl Fn(f64) -> (f64, f64)) -> String { + let (x0, y0) = f(0.0); + let mut s = String::from("M 0 0"); + for k in 1..=n { + let (x, y) = f(k as f64 / n as f64); + let _ = write!(s, " L {:.4} {:.4}", x - x0, y - y0); + } + if closed { + s.push_str(" Z"); + } + s.push_str(" E"); + s + } + fn pts(p: &[(f64, f64)], closed: bool) -> String { + let mut s = String::from("M 0 0"); + for (x, y) in p { + let _ = write!(s, " L {x:.4} {y:.4}"); + } + if closed { + s.push_str(" Z"); + } + s.push_str(" E"); + s + } + // A closed curve in polar form r(θ), starting at its top. + fn polar(scale: f64, r: impl Fn(f64) -> f64) -> String { + poly(96, true, |t| { + let a = -PI / 2.0 + TAU * t; + let rr = r(a + PI / 2.0) * scale; + (rr * a.cos(), rr * a.sin()) + }) + } + fn star(n: usize, outer: f64, inner: f64) -> String { + let mut p = Vec::new(); + let start = (0.0, -outer); + for k in 1..=2 * n { + let a = -PI / 2.0 + PI * k as f64 / n as f64; + let r = if k % 2 == 1 { inner } else { outer }; + p.push((r * a.cos() - start.0, r * a.sin() - start.1)); + } + pts(&p, true) + } + let spiral = |dir: f64| { + poly(120, false, move |t| { + let r = 0.12 * (1.0 - t); + let a = dir * 3.0 * TAU * t; + (dir * (0.12 - r * a.cos()), -r * a.sin()) + }) + }; + let bounce = |dir: f64| poly(90, false, move |t| (dir * 0.3 * t, 0.15 * t - 0.1 * (1.0 - t) * (3.0 * PI * t).sin().abs())); + Some(match effect { + "diagonalDownRight" => pts(&[(0.2, 0.2)], false), + "diagonalUpRight" => pts(&[(0.2, -0.2)], false), + "zigzag" => pts(&[(0.05, 0.04), (0.1, 0.0), (0.15, 0.04), (0.2, 0.0), (0.25, 0.04), (0.3, 0.0)], false), + "sineWave" => poly(64, false, |t| (0.3 * t, 0.04 * (2.0 * TAU * t).sin())), + "pathWave" => poly(48, false, |t| (0.3 * t, 0.06 * (TAU * t).sin())), + "decayingWave" => poly(96, false, |t| (0.3 * t, 0.07 * (-3.0 * t).exp() * (3.0 * TAU * t).sin())), + "heartbeat" => pts(&[(0.08, 0.0), (0.1, -0.08), (0.13, 0.06), (0.15, 0.0), (0.25, 0.0)], false), + "sCurve1" => poly(48, false, |t| (0.2 * t, 0.1 - 0.1 * (PI * t).cos())), + "sCurve2" => poly(48, false, |t| (0.1 - 0.1 * (PI * t).cos(), 0.2 * t)), + "stairsDown" => pts(&[(0.06, 0.0), (0.06, 0.06), (0.12, 0.06), (0.12, 0.12), (0.18, 0.12), (0.18, 0.18)], false), + "bounceRight" => bounce(1.0), + "bounceLeft" => bounce(-1.0), + "spiralRight" => spiral(1.0), + "spiralLeft" => spiral(-1.0), + "spring" => poly(160, false, |t| (0.3 * t + 0.03 * (5.0 * TAU * t).cos(), 0.03 * (5.0 * TAU * t).sin())), + "funnel" => poly(160, false, |t| { + let r = 0.12 * (1.0 - t); + (r * (3.0 * TAU * t).cos(), 0.25 * t + 0.03 * (3.0 * TAU * t).sin() * (1.0 - t)) + }), + "swoosh" => poly(48, false, |t| (0.3 * t, -0.12 * (PI * t).sin() + 0.1 * t * t)), + "star4" => star(4, 0.1, 0.04), + "star5" => star(5, 0.1, 0.04), + "star6" => star(6, 0.1, 0.05), + "star8" => star(8, 0.1, 0.06), + "heart" => poly(96, true, |t| { + let a = TAU * t; + (0.006 * 16.0 * a.sin().powi(3), -0.006 * (13.0 * a.cos() - 5.0 * (2.0 * a).cos() - 2.0 * (3.0 * a).cos() - (4.0 * a).cos())) + }), + "crescentMoon" => { + // The outer arc of a circle, back along a smaller, offset one. + let mut p: Vec<(f64, f64)> = (1..=32) + .map(|k| { + let a = -PI / 2.0 - PI * k as f64 / 32.0; + (0.1 * a.cos(), 0.1 + 0.1 * a.sin()) + }) + .collect(); + p.extend((1..=32).map(|k| { + let a = PI / 2.0 - PI * k as f64 / 32.0; + (0.06 * a.cos() - 0.03, 0.1 + 0.1 * a.sin()) + })); + pts(&p, true) + } + "teardrop" => polar(0.1, |a| 0.6 + 0.4 * (a / 2.0).cos().abs().powf(3.0)), + "football" => poly(64, true, |t| { + let a = TAU * t; + (0.12 * a.sin(), 0.06 * (1.0 - a.cos()) * (1.0 - 0.3 * (2.0 * a).cos().abs())) + }), + "plusPath" => pts( + &[ + (0.04, 0.0), + (0.04, 0.04), + (0.08, 0.04), + (0.08, 0.08), + (0.04, 0.08), + (0.04, 0.12), + (0.0, 0.12), + (0.0, 0.08), + (-0.04, 0.08), + (-0.04, 0.04), + (0.0, 0.04), + ], + true, + ), + "rightTriangle" => pts(&[(0.0, 0.15), (0.15, 0.15)], true), + "curvedSquare" => poly(96, true, |t| { + let a = -PI / 2.0 + TAU * t; + let sg = |v: f64| v.signum() * v.abs().sqrt(); + (0.1 * sg(a.cos()), 0.1 + 0.1 * sg(a.sin())) + }), + "figure8Horizontal" => poly(96, true, |t| { + let a = TAU * t; + (0.15 * a.sin(), 0.06 * (2.0 * a).sin()) + }), + "figure8Vertical" => poly(96, true, |t| { + let a = TAU * t; + (0.06 * (2.0 * a).sin(), 0.15 * (1.0 - a.cos())) + }), + "loopDeLoop" => poly(120, false, |t| (0.3 * t - 0.05 * (2.0 * TAU * t).sin(), -0.05 * (1.0 - (2.0 * TAU * t).cos()))), + "bean" => polar(0.1, |a| 0.75 + 0.25 * a.cos() + 0.15 * (2.0 * a).cos()), + "peanut" => polar(0.1, |a| 0.7 + 0.3 * (2.0 * a).cos().abs()), + "neutron" => polar(0.1, |a| 0.35 + 0.65 * (3.0 * a).cos().abs()), + _ => return None, + }) +} + #[cfg(test)] mod option_label_tests { use super::*; diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 7a3d05bd..1ef690a0 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -54,7 +54,7 @@ perspective-correct subdivided meshes. | Entrance | ~50 | 24 | Plus, Wedge, Basic Zoom, Center Revolve, Compress, Stretch, Boomerang, Credits, Curve Up, Drop, Flip, Float, Pinwheel, Spiral In, Whip, Ascend, Descend, Unfold | | Emphasis | ~25 | 17 | Brush Color, Bold Reveal, Contrasting Color, Flicker, Grow With Color, Shimmer, Blink | | Exit | ~50 | 14 | Mirrors of the missing entrance effects, plus Blinds, Box, Checkerboard, Circle, Diamond, Peek Out, Strips as exits | -| Motion paths | 64 presets + custom | 6 (Lines, Arcs, Turns, Loops, Shapes-style presets, Custom Path) | Most of Basic (shapes), Lines & Curves and Special presets | +| Motion paths | 64 presets + custom | 40 + Custom Path (Lines, Arcs, Turns, Loops, Basic shapes such as stars, heart, moon, teardrop; Lines & Curves such as zigzag, waves, S curves, spirals, bounces; Special such as figure 8, bean, peanut, neutron) | Remaining ~24 presets (some Basic shapes, Lines & Curves variants) | | Media (Play, Pause, Stop) | 3 (+ Seek) | 3 | Seek, bookmarks as triggers | By how often people use them, the effects present cover nearly all real decks (Appear, Fade, Fly