Merge PR #551 from NikitkaRa: Animations: add 34 PowerPoint motion path presets

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:08:52 -04:00
6 changed files with 225 additions and 3 deletions
+1
View File
@@ -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 {
+22 -1
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -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")),
}
}
+23
View File
@@ -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<u32> = 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')));
}
+177
View File
@@ -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<String> {
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::*;
+1 -1
View File
@@ -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