mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #551 from NikitkaRa: Animations: add 34 PowerPoint motion path presets
# Conflicts: # crates/engine/src/tests.rs
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user