collective

Browse / github.com/storytold/deckcraft / patch

Animations: Anvil, the Keynote-style entrance

Animations: Anvil, the Keynote-style entrance ## What A new entrance effect, **Anvil**, the one Keynote users know and miss in PowerPoint. The shape free-falls from above the slide, squashes on landing (its base stays on the ground), springs back, and kicks up a puff of dust that billows out sideways. It's in the Animations ribbon's quick gallery next to Zoom, and in the full gallery and `animation.list`, like any other entrance. ## How - **anim** (`effects.rs`, `lib.rs`): the `anvil` entrance. A gravity drop (distance ∝ t²) over the first 45 % of the effect, then a squash that rings out (`scale_y` down, `scale_x` up, offset so the

Status
open
Score
0.00
Adopters
0 other operators
Author
SimonGadgeteer (imported from pull request #1045, open)
Base
f34f4121c8ee720ca08e5d47b912155a33504e69
Size
+182 −4 in 11 files
Review
pending
Shared
2026-10-28
Id
2ca8e2eefa28e430e6432696

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/anim/src/effects.rs crates/anim/src/lib.rs crates/anim/src/tests.rs crates/model/src/anim.rs crates/pptx/src/write/timing.rs crates/render/src/lib.rs crates/ui-egui/src/preview.rs crates/ui-egui/src/ribbon.rs crates/ui-egui/src/show.rs docs/animation-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-19 | Animations: Anvil, the Keynote-style entrance. The shape drops from above the slide, squashes on landing and kicks up a puff of dust; in the quick gallery. PPTX keeps it as preset 1001 with a drop and squash PowerPoint can play, and reopens it as Anvil | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |diff --git a/crates/anim/src/effects.rs b/crates/anim/src/effects.rs--- a/crates/anim/src/effects.rs+++ b/crates/anim/src/effects.rs@@ -239,6 +239,25 @@ impl EffectSpec {                 st.scale_x = 0.3 + 0.7 * e;                 st.opacity = e;             }+            "anvil" => {+                // Keynote's Anvil: a free fall from above the slide (gravity: distance grows with t²),+                // then a squash on impact that rings out, with a puff of dust at the base. The curve is+                // the effect itself, so it ignores smoothing. Squashing keeps the base on the ground.+                const LAND: f64 = 0.45;+                if p < LAND {+                    let t = p / LAND;+                    st.offset_y = -(y + h) * (1.0 - t * t);+                } else {+                    let u = ((p - LAND) / (1.0 - LAND)).clamp(0.0, 1.0);+                    let squash = 0.14 * (u / 0.05).min(1.0) * (-4.5 * u).exp() * (2.5 * PI * u).cos();+                    st.scale_y = 1.0 - squash;+                    st.scale_x = 1.0 + 0.6 * squash;+                    st.offset_y = h * squash / 2.0;+                    if !exit && u < 1.0 {+                        st.dust = u.max(1e-3);+                    }+                }+            }             "spinner" => {                 st.rotate = 360.0 * q;                 st.scale_x = 0.5 + 0.5 * e;diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs--- a/crates/anim/src/lib.rs+++ b/crates/anim/src/lib.rs@@ -60,11 +60,25 @@ pub struct AnimState {     pub rotate: f64,     pub clip: Option<[f64; 4]>,     pub tint: Option<Rgba>,+    /// Progress (0..1) of a dust puff at the shape's base (Anvil's landing); 0 = none.+    #[serde(default)]+    pub dust: f64, }  impl Default for AnimState {     fn default() -> Self {-        AnimState { visible: true, opacity: 1.0, offset_x: 0.0, offset_y: 0.0, scale_x: 1.0, scale_y: 1.0, rotate: 0.0, clip: None, tint: None }+        AnimState {+            visible: true,+            opacity: 1.0,+            offset_x: 0.0,+            offset_y: 0.0,+            scale_x: 1.0,+            scale_y: 1.0,+            rotate: 0.0,+            clip: None,+            tint: None,+            dust: 0.0,+        }     } } @@ -78,7 +92,7 @@ impl AnimState {         *self == AnimState::default()     }     /// Apply `d` on top of `self`: opacities and scales multiply, offsets and rotations add, clips-    /// intersect, a tint replaces.+    /// intersect, a tint replaces, the larger dust puff wins.     pub fn compose(&self, d: &AnimState) -> AnimState {         let clip = match (self.clip, d.clip) {             (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),@@ -95,6 +109,7 @@ impl AnimState {             rotate: self.rotate + d.rotate,             clip,             tint: d.tint.or(self.tint),+            dust: self.dust.max(d.dust),         }     }     /// Replace any non-finite number with its neutral value and clamp opacity and clip.@@ -106,6 +121,7 @@ impl AnimState {         self.scale_x = f(self.scale_x, 1.0);         self.scale_y = f(self.scale_y, 1.0);         self.rotate = f(self.rotate, 0.0);+        self.dust = f(self.dust, 0.0).clamp(0.0, 1.0);         self.clip = self.clip.map(|c| {             let l = f(c[0], 0.0).clamp(0.0, 1.0);             let t = f(c[1], 0.0).clamp(0.0, 1.0);diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs--- a/crates/anim/src/tests.rs+++ b/crates/anim/src/tests.rs@@ -191,6 +191,25 @@ fn every_animation_evaluates_finite() {     } } +#[test]+fn anvil_drops_from_above_squashes_and_puffs_dust() {+    let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Entrance, "anvil", AnimStart::OnClick)]), W, H);+    // Shape at y=100, h=100: starts fully above the slide, falling faster as it goes.+    let s0 = tl.state(ShapeId(1), None, 0, 1e-6);+    assert!(s0.visible && s0.offset_y <= -199.0, "{s0:?}");+    let (a, b, c) = (tl.state(ShapeId(1), None, 0, 0.1), tl.state(ShapeId(1), None, 0, 0.2), tl.state(ShapeId(1), None, 0, 0.3));+    assert!(b.offset_y - a.offset_y < c.offset_y - b.offset_y, "accelerates: {a:?} {b:?} {c:?}");+    assert_eq!(a.dust, 0.0);+    // Just after landing (45 %): flattened and widened with the base kept on the ground, dust rising.+    let hit = tl.state(ShapeId(1), None, 0, 0.5);+    assert!(hit.scale_y < 0.95 && hit.scale_x > 1.02, "{hit:?}");+    assert!((hit.offset_y - 100.0 * (1.0 - hit.scale_y) / 2.0).abs() < 1e-9, "{hit:?}");+    assert!(hit.dust > 0.0 && hit.dust < 1.0);+    // Done: at rest, no dust.+    let end = tl.state(ShapeId(1), None, 1, 0.0);+    assert!(end.is_identity(), "{end:?}");+}+ #[test] fn fly_starts_off_slide() {     let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick);diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs--- a/crates/model/src/anim.rs+++ b/crates/model/src/anim.rs@@ -342,6 +342,10 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[     ("expand", "Expand", AnimClass::Entrance, 55, 500, &[]),     ("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]),     ("spinner", "Spinner", AnimClass::Entrance, 49, 1000, &[]),+    // Keynote-style Anvil: drops from above the slide, lands with a squash and a puff of dust.+    // PowerPoint has no such preset; 1001 is a DeckCraft id outside PowerPoint's range, and the file+    // carries a drop-and-squash PowerPoint can play.+    ("anvil", "Anvil", AnimClass::Entrance, 1001, 1000, &[]),     ("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]),     ("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 500, &[]),     ("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]),diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs--- a/crates/pptx/src/write/timing.rs+++ b/crates/pptx/src/write/timing.rs@@ -192,6 +192,48 @@ fn anim_prop(w: &mut W, ids: &mut Ids, e: &Eff, dur: u32, attr: &str, from: &str     w.close("p:anim"); } +/// Anvil as PowerPoint can play it: a gravity drop from above the slide sampled as key times+/// (distance grows with t²), landing at 45 % of the effect, then a squash flattened against the+/// ground (the box shifted down while it shrinks) that springs back. No dust: PowerPoint has none.+fn anvil(w: &mut W, ids: &mut Ids, e: &Eff, dur: u32) {+    let mut keys: Vec<(u32, String)> = (0..=6)+        .map(|k| {+            let t = k as f64 / 6.0;+            let tm = (t * 45_000.0).round() as u32;+            let rest = 1.0 - t * t;+            let v = if k == 6 { "#ppt_y".to_string() } else { format!("#ppt_y-(#ppt_y+#ppt_h/2)*{rest:.4}") };+            (tm, v)+        })+        .collect();+    keys.extend([(52_000, "#ppt_y+#ppt_h*0.06".to_string()), (60_000, "#ppt_y".to_string()), (100_000, "#ppt_y".to_string())]);+    w.open("p:anim", A::new().a("calcmode", "lin").a("valueType", "num"));+    cbhvr(w, ids, e, dur, None, &["ppt_y"], Some("base"));+    w.open0("p:tavLst");+    for (tm, v) in &keys {+        w.open("p:tav", A::new().a("tm", *tm));+        w.open0("p:val");+        w.val("p:strVal", v);+        w.close("p:val");+        w.close("p:tav");+    }+    w.close("p:tavLst");+    w.close("p:anim");+    // The squash: from the landing, flatten and widen, then spring back (autoRev).+    let at = (u64::from(dur) * 45 / 100) as u32;+    let half = ((u64::from(dur) * 7 / 100) as u32).max(1);+    w.open0("p:animScale");+    w.open0("p:cBhvr");+    w.open("p:cTn", A::new().a("id", ids.next()).a("dur", half).a("autoRev", "1").a("fill", "hold"));+    w.open0("p:stCondLst");+    w.empty("p:cond", A::new().a("delay", at));+    w.close("p:stCondLst");+    w.close("p:cTn");+    tgt(w, e.spid, e.paragraph);+    w.close("p:cBhvr");+    w.empty("p:by", A::new().a("x", 108_000).a("y", 88_000));+    w.close("p:animScale");+}+ fn filter_for(effect: &str, option: &str) -> &'static str {     match effect {         "wipe" | "wipeOut" => match option {@@ -318,6 +360,7 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) {                     anim_prop(w, ids, e, dur, "ppt_h", "0", "#ppt_h");                     anim_effect(w, ids, e, dur, "fade", false);                 }+                "anvil" => anvil(w, ids, e, dur),                 "float" | "rise" => {                     anim_prop(w, ids, e, dur, "ppt_y", if a.option == "d" { "#ppt_y-.1" } else { "#ppt_y+.1" }, "#ppt_y");                     anim_effect(w, ids, e, dur, "fade", false);diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -184,13 +184,25 @@ pub struct ShapeState {     pub clip: Option<[f64; 4]>,     /// Colour override for emphasis effects (fill).     pub tint: Option<Rgba>,+    /// Progress (0..1) of a dust puff at the shape's base (Anvil's landing); 0 = none.+    pub dust: f64,     /// Per-paragraph states of by-paragraph text builds (paragraph index, state).     pub paras: Vec<(usize, ParaState)>, }  impl Default for ShapeState {     fn default() -> Self {-        ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None, paras: vec![] }+        ShapeState {+            visible: true,+            opacity: 1.0,+            offset: Vec2::ZERO,+            scale: (1.0, 1.0),+            rotate: 0.0,+            clip: None,+            tint: None,+            dust: 0.0,+            paras: vec![],+        }     } } @@ -504,6 +516,55 @@ fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) {     (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16) } +/// Anvil's landing dust: soft grey clouds billowing out sideways from the base of the shape's box+/// (`m`: shape-local points to output pixels), `u` the puff's progress 0..1. Deterministic, so+/// every frame and export of the same moment match.+fn dust_puff(ctx: &mut RenderContext, m: Affine, w: f64, h: f64, u: f64) {+    if !(u.is_finite() && w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {+        return;+    }+    // The ground: the lowest edge of the (possibly rotated) box, in slide space.+    let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)].map(|p| m * p);+    let (mut l, mut r, mut b) = (f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);+    for p in corners {+        l = l.min(p.x);+        r = r.max(p.x);+        b = b.max(p.y);+    }+    let bw = r - l;+    if !(bw.is_finite() && b.is_finite()) || bw <= 0.0 {+        return;+    }+    let cx = (l + r) / 2.0;+    let out = 1.0 - (1.0 - u).powi(3);+    let alpha = 0.6 * (u / 0.08).min(1.0) * (1.0 - u).powf(1.6);+    // Size the clouds from the box width in output pixels, within sensible bounds.+    let base = (bw * 0.09).clamp(4.0, 120.0);+    let tone = Rgba::rgb(0xB9, 0xB4, 0xAC);+    ctx.set_transform(Affine::IDENTITY);+    const N: usize = 12;+    for k in 0..N {+        // Fixed pseudo-random spread per puff.+        let a = ((k as f64 * 0.618_034) % 1.0 + 0.13 * (k % 3) as f64) % 1.0;+        let side = if k % 2 == 0 { -1.0 } else { 1.0 };+        let start = cx + side * bw / 2.0 * (0.25 + 0.75 * a);+        let px = start + side * bw * (0.12 + 0.38 * a) * out;+        let py = b - base * (0.2 + 0.9 * a) * out;+        let rad = base * (0.8 + 0.9 * out) * (0.75 + 0.5 * ((k * 7 % 5) as f64 / 4.0));+        if !(px.is_finite() && py.is_finite() && rad.is_finite()) || rad <= 0.0 {+            continue;+        }+        let c = Point::new(px, py);+        let stops = [+            peniko::ColorStop { offset: 0.0, color: color(tone, alpha).into() },+            peniko::ColorStop { offset: 0.55, color: color(tone, alpha * 0.55).into() },+            peniko::ColorStop { offset: 1.0, color: color(tone, 0.0).into() },+        ];+        ctx.set_paint(peniko::Gradient::new_radial(c, rad as f32).with_stops(stops.as_slice()));+        ctx.fill_rect(&Rect::new(px - rad, py - rad, px + rad, py + rad));+    }+}+ pub(crate) fn color(c: Rgba, alpha: f64) -> peniko::Color {     let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;     peniko::Color::from_rgba8(c.r, c.g, c.b, a)@@ -990,6 +1051,9 @@ impl Renderer {         if layered {             ctx.pop_layer();         }+        if st.dust > 0.0 && st.dust < 1.0 {+            dust_puff(ctx, parent * x.affine(), x.w, x.h, st.dust);+        }     }      #[allow(clippy::too_many_arguments)]diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs--- a/crates/ui-egui/src/preview.rs+++ b/crates/ui-egui/src/preview.rs@@ -20,6 +20,7 @@ pub fn shape_state(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeStat         rotate: a.rotate,         clip: a.clip,         tint: a.tint,+        dust: a.dust,         paras: tl             .paragraph_targets(id)             .into_iter()diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs--- a/crates/ui-egui/src/ribbon.rs+++ b/crates/ui-egui/src/ribbon.rs@@ -1901,6 +1901,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) {                         | "split"                         | "wipe"                         | "zoom"+                        | "anvil"                         | "pulse"                         | "spin"                         | "growShrink"diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs--- a/crates/ui-egui/src/show.rs+++ b/crates/ui-egui/src/show.rs@@ -356,6 +356,7 @@ fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPl         rotate: a.rotate,         clip: a.clip,         tint: a.tint,+        dust: a.dust,         paras: tl             .paragraph_targets(id)             .into_iter()diff --git a/docs/animation-parity.md b/docs/animation-parity.md--- a/docs/animation-parity.md+++ b/docs/animation-parity.md@@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-19 · **Change:** trivial (Anvil, a Keynote-style entrance beyond PowerPoint; previous: minor, community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)  Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)@@ -57,6 +57,14 @@ perspective-correct subdivided meshes. | 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 | +Beyond PowerPoint: **Anvil** (entrance, in the ribbon's quick gallery), the effect Keynote users+know. The shape free-falls from above the slide, squashes on landing with its base kept on the+ground, and a puff of dust billows out sideways (`AnimState::dust`, drawn by the renderer). PowerPoint+has no such preset, so PPTX stores it under DeckCraft's preset id 1001 with behaviours PowerPoint+can play: the gravity drop as `ppt_y` key times and an `animScale` squash that springs back (no+dust). DeckCraft reads id 1001 back as Anvil (`anvil_drops_from_above_squashes_and_puffs_dust`,+`every_animation_effect_survives`). It is not counted in the PowerPoint scores above.+ By how often people use them, the effects present cover nearly all real decks (Appear, Fade, Fly In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85% of use; by count, ~34%.@@ -94,6 +102,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85  | Date | Change | Summary | |---|---|---|+| 2026-10-19 | trivial | Anvil: a Keynote-style entrance beyond PowerPoint (drop, squash, dust), saved to PPTX as a drop PowerPoint can play; not counted in the scores | | 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores | | 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) | | 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |