collective

Browse / github.com/storytold/deckcraft / patch

Animations: Dissolve In and Out show the shape through noise instead of fading

Animations: Dissolve In and Out show the shape through noise instead of fading Dissolve In and Out were played as a plain fade. PowerPoint shows the shape through a fine noise that fills in until the shape is whole, so a dissolving shape looks speckled midway and a fading one looks gray. Seen in a real deck: several shapes on a slide animated with Dissolve In (`presetID 9`, `<p:animEffect transition="in" filter="dissolve"/>`) faded in on DeckCraft and dissolved in PowerPoint. Cause: the effect was mapped to opacity along with Fade, Random Bars, Blinds, Checkerboard and Wheel (`st.opacity = e` in `effects.rs`), and the animation state had nothing that can show part

Status
open
Score
0.00
Adopters
0 other operators
Author
leobasilio (imported from pull request #1393, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+360 −15 in 12 files
Review
pending
Shared
2026-10-28
Id
6e35e592e65eefa237fdabbb

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/render/src/dissolve.rs crates/render/src/lib.rs crates/render/src/opts.rs crates/render/src/renderer.rs crates/render/src/tests.rs crates/ui-egui/src/canvas/preview.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@@ -2,9 +2,9 @@  **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)-> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)-> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Dissolve In and Out show the shape through noise instead of fading; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Dissolve In and Out show the shape through noise instead of fading; previous: trivial, progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Dissolve In and Out show the shape through noise instead of fading; previous: trivial, progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)@@ -138,6 +138,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |+| 2026-10-25 | Animations: Dissolve In and Out show the shape through noise instead of fading. The pattern is fixed in slide space (about 2 x 1.4 pt blobs, the same at any resolution), what has shown never goes again, and the share shown follows PowerPoint's S over the whole duration, all measured on PowerPoint for Mac's video export at 1080p and 4K | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | | 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |@@ -295,6 +296,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |+| 2026-10-25 | trivial | Progress log: Dissolve In and Out as a noise mask. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged | | 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |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@@ -76,6 +76,16 @@ pub(crate) fn emphasis_persists(effect: &str) -> bool {     ) } +/// The share of a dissolving shape that shows at progress `p`: PowerPoint's Dissolve In is an S,+/// nothing before about a tenth of the way and everything by nine tenths. Measured on its video+/// export of 4 s effects (1080p and 4K agree) as .003 .040 .140 .305 .508 .707 .866 .962 .997 at+/// p = .1 to .9; the symmetric Beta(4,4) CDF is within 0.015 of that.+pub(crate) fn dissolve_share(p: f64) -> f64 {+    let p = crate::clampf(p, 0.0, 1.0, 0.0);+    let p2 = p * p;+    p2 * p2 * (35.0 + p * (-84.0 + p * (70.0 - 20.0 * p)))+}+ 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()@@ -131,7 +141,8 @@ impl EffectSpec {         };         match self.effect.as_str() {             "appear" => st.visible = p > 0.0,-            "fade" | "dissolve" | "randomBars" | "blinds" | "checkerboard" | "wheel" => st.opacity = e,+            "fade" | "randomBars" | "blinds" | "checkerboard" | "wheel" => st.opacity = e,+            "dissolve" => st.dissolve = Some(dissolve_share(e)),             "fly" => {                 let o = if o.is_empty() { "b" } else { o };                 if o.contains('l') {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@@ -47,8 +47,8 @@ pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition /// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show. /// /// Same meaning as the renderer's `ShapeState`: translation in points, scale and rotation (degrees)-/// around the shape's centre, a reveal clip in shape-local unit space `[l, t, r, b]`, and a fill-/// colour override.+/// around the shape's centre, a reveal clip in shape-local unit space `[l, t, r, b]`, a fill+/// colour override, and a dissolve level. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct AnimState {     pub visible: bool,@@ -60,11 +60,25 @@ pub struct AnimState {     pub rotate: f64,     pub clip: Option<[f64; 4]>,     pub tint: Option<Rgba>,+    /// The share (0..1) of the shape's area a dissolve effect shows: which parts is up to the+    /// renderer's fixed noise, so what shows only ever grows with the level.+    pub dissolve: Option<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,+            dissolve: None,+        }     } } @@ -77,8 +91,12 @@ impl AnimState {     pub fn is_identity(&self) -> bool {         *self == AnimState::default()     }+    /// Opacity for a part that cannot dissolve (a paragraph of a text build): a dissolve fades.+    pub fn fade_opacity(&self) -> f64 {+        self.opacity * self.dissolve.unwrap_or(1.0)+    }     /// Apply `d` on top of `self`: opacities and scales multiply, offsets and rotations add, clips-    /// intersect, a tint replaces.+    /// intersect, a tint replaces, dissolves show only what both show.     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 +113,11 @@ impl AnimState {             rotate: self.rotate + d.rotate,             clip,             tint: d.tint.or(self.tint),+            dissolve: match (self.dissolve, d.dissolve) {+                (Some(a), Some(b)) => Some(a.min(b)),+                (a, None) => a,+                (None, b) => b,+            },         }     }     /// Replace any non-finite number with its neutral value and clamp opacity and clip.@@ -106,6 +129,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.dissolve = self.dissolve.map(|d| f(d, 1.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@@ -35,6 +35,7 @@ fn finite(s: &AnimState) -> bool {         && s.scale_y.is_finite()         && s.rotate.is_finite()         && s.clip.is_none_or(|c| c.iter().all(|v| v.is_finite()))+        && s.dissolve.is_none_or(|d| d.is_finite() && (0.0..=1.0).contains(&d)) }  #[test]@@ -1135,3 +1136,61 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {     assert_eq!(tl.effects().len(), 6_000);     assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]); }++#[test]+fn dissolve_in_shows_a_growing_share_not_a_fade() {+    let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Entrance, "dissolve", AnimStart::OnClick)]), W, H);+    let mut last = -1.0;+    // Hidden until the effect starts.+    assert!(!tl.state(ShapeId(1), None, 0, 0.0).visible);+    for t in [0.01, 0.1, 0.25, 0.4, 0.5, 0.6, 0.75, 0.9, 0.99] {+        let s = tl.state(ShapeId(1), None, 0, t);+        // The shape stays opaque: the renderer decides which parts show.+        assert!(s.visible && (s.opacity - 1.0).abs() < 1e-12, "{t}: {s:?}");+        let d = s.dissolve.expect("a dissolve level while it plays");+        assert!(d >= last, "the share only grows: {last} then {d} at {t}");+        last = d;+    }+    assert!(last > 0.999, "all of it shows by the end: {last}");+    // Done: nothing left to apply.+    assert!(tl.state(ShapeId(1), None, 0, 1.0).is_identity());+    assert!(tl.state(ShapeId(1), None, 1, 0.0).is_identity());+}++#[test]+fn dissolve_share_follows_powerpoints_s_curve() {+    // PowerPoint's Dissolve In on a 4 s effect, from its video export, at p = .1 .. .9.+    let measured = [0.003, 0.040, 0.140, 0.305, 0.508, 0.707, 0.866, 0.962, 0.997];+    for (i, m) in measured.iter().enumerate() {+        let p = (i + 1) as f64 / 10.0;+        let share = crate::effects::dissolve_share(p);+        assert!((share - m).abs() < 0.02, "p={p}: {share} against {m}");+    }+    assert_eq!(crate::effects::dissolve_share(0.0), 0.0);+    assert!((crate::effects::dissolve_share(1.0) - 1.0).abs() < 1e-12);+    assert_eq!(crate::effects::dissolve_share(f64::NAN), 0.0);+}++#[test]+fn dissolve_out_takes_back_what_dissolve_in_shows() {+    let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Exit, "dissolveOut", AnimStart::OnClick)]), W, H);+    let (start, mid, end) = (tl.state(ShapeId(1), None, 0, 0.01), tl.state(ShapeId(1), None, 0, 0.5), tl.state(ShapeId(1), None, 0, 0.99));+    assert!(start.visible && start.dissolve.is_some_and(|d| d > 0.99));+    assert!(mid.dissolve.is_some_and(|d| (d - 0.5).abs() < 0.05));+    assert!(end.dissolve.is_some_and(|d| d < 0.05));+    assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible);+}++#[test]+fn dissolves_compose_to_the_smaller_share_and_stay_sane() {+    let a = AnimState { dissolve: Some(0.7), ..Default::default() };+    let b = AnimState { dissolve: Some(0.4), ..Default::default() };+    assert_eq!(a.compose(&b).dissolve, Some(0.4));+    assert_eq!(a.compose(&AnimState::default()).dissolve, Some(0.7));+    assert_eq!(AnimState::default().compose(&b).dissolve, Some(0.4));+    assert_eq!(AnimState { dissolve: Some(f64::NAN), ..Default::default() }.sanitized().dissolve, Some(1.0));+    assert_eq!(AnimState { dissolve: Some(7.0), ..Default::default() }.sanitized().dissolve, Some(1.0));+    // A part that cannot dissolve fades instead.+    assert!((AnimState { opacity: 0.5, dissolve: Some(0.5), ..Default::default() }.fade_opacity() - 0.25).abs() < 1e-12);+    assert!(!AnimState { dissolve: Some(0.5), ..Default::default() }.is_identity());+}diff --git a/crates/render/src/dissolve.rs b/crates/render/src/dissolve.rsnew file mode 100644--- /dev/null+++ b/crates/render/src/dissolve.rs@@ -0,0 +1,125 @@+//! Dissolve In and Out: a shape shows through a field of smooth noise.+//!+//! Measured on PowerPoint's video export (1080p and 4K): the pattern is a fixed map, so a part that+//! has shown never goes again as the level grows, and it is the same blobs at every resolution,+//! about 1 to 3 pt across and wider than tall. Here that is value noise on a grid of cells+//! `CELL_W` x `CELL_H` pt in slide space with smooth interpolation (the cell size is the best fit of+//! simulated noise against the measured autocorrelation), seeded by the shape.++use std::sync::OnceLock;++use kurbo::{Affine, Rect};+use vello_cpu::Mask;++/// Grid cell of the noise, in points.+const CELL_W: f64 = 2.0;+const CELL_H: f64 = 1.4;+/// Most mask pixels one shape gets; a bigger canvas falls back to a fade.+const MAX_MASK_PIXELS: usize = 1 << 26;++/// A hash of a grid corner as a number in `[0, 1)`.+fn corner(x: i64, y: i64, seed: u64) -> f64 {+    let mut z = seed ^ (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F);+    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);+    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);+    z ^= z >> 31;+    (z >> 11) as f64 / (1u64 << 53) as f64+}++fn smooth(t: f64) -> f64 {+    t * t * (3.0 - 2.0 * t)+}++/// Where the values of the noise fall: `QUANTILES[i]` is the value that `i / 256` of the field lies+/// below. Interpolated noise averages its corners, so its values bunch round the middle; asking for+/// a share of the shape means asking for a value from here.+fn quantiles() -> &'static [f64; 257] {+    static Q: OnceLock<[f64; 257]> = OnceLock::new();+    Q.get_or_init(|| {+        const N: u64 = 1 << 16;+        let mut v: Vec<f64> = (0..N)+            .map(|i| {+                let c = |k: u64| corner(i as i64, k as i64, 0x51ED_270B);+                let (fx, fy) = (smooth(c(4)), smooth(c(5)));+                let top = c(0) + (c(1) - c(0)) * fx;+                let bottom = c(2) + (c(3) - c(2)) * fx;+                top + (bottom - top) * fy+            })+            .collect();+        v.sort_by(f64::total_cmp);+        let mut q = [0.0; 257];+        for (i, slot) in q.iter_mut().enumerate() {+            *slot = v.get(((i as u64 * (N - 1)) / 256) as usize).copied().unwrap_or(0.5);+        }+        q[0] = 0.0;+        q[256] = 1.0;+        q+    })+}++/// The noise value below which `share` (0..1) of the shape lies.+fn value_below(share: f64) -> f64 {+    let q = quantiles();+    let x = share.clamp(0.0, 1.0) * 256.0;+    let i = (x as usize).min(255);+    let f = x - i as f64;+    match (q.get(i), q.get(i + 1)) {+        (Some(a), Some(b)) => a + (b - a) * f,+        _ => 1.0,+    }+}++/// What a dissolve at `share` shows: a mask for the whole canvas (`w` x `h` px, as the layer needs),+/// nonzero only inside `area` (device pixels). `view` maps slide points to device pixels and only+/// scales and translates. `None` when the canvas is too big for a mask.+pub(crate) fn mask(w: u16, h: u16, area: Rect, view: Affine, seed: u64, share: f64) -> Option<Mask> {+    let (cw, ch) = (usize::from(w), usize::from(h));+    if cw == 0 || ch == 0 || cw * ch > MAX_MASK_PIXELS {+        return None;+    }+    let mut data = vec![0u8; cw * ch];+    let [sx, _, _, sy, tx, ty] = view.as_coeffs();+    if sx.abs() < 1e-9 || sy.abs() < 1e-9 || !area.is_finite() {+        return Some(Mask::from_parts(data, w, h));+    }+    let limit = value_below(share);+    let x0 = area.x0.floor().max(0.0) as usize;+    let x1 = (area.x1.ceil().max(0.0) as usize).min(cw);+    let y0 = area.y0.floor().max(0.0) as usize;+    let y1 = (area.y1.ceil().max(0.0) as usize).min(ch);+    if x0 >= x1 || y0 >= y1 {+        return Some(Mask::from_parts(data, w, h));+    }+    // Per column: the grid cell and the smooth position inside it; the same per row.+    let grid = |px: usize, scale: f64, offset: f64, cell: f64| {+        let g = ((px as f64 + 0.5 - offset) / scale) / cell;+        let i = g.floor();+        (i as i64, smooth(g - i))+    };+    let cols: Vec<(i64, f64)> = (x0..x1).map(|px| grid(px, sx, tx, CELL_W)).collect();+    let (first, last) = match (cols.iter().map(|c| c.0).min(), cols.iter().map(|c| c.0).max()) {+        (Some(a), Some(b)) => (a, b),+        _ => return Some(Mask::from_parts(data, w, h)),+    };+    // The corners of the current row of cells, and of the one below it.+    let mut row_cache: Option<(i64, Vec<f64>, Vec<f64>)> = None;+    for py in y0..y1 {+        let (gy, fy) = grid(py, sy, ty, CELL_H);+        if row_cache.as_ref().is_none_or(|r| r.0 != gy) {+            let line = |y: i64| (first..=last + 1).map(|x| corner(x, y, seed)).collect::<Vec<f64>>();+            row_cache = Some((gy, line(gy), line(gy + 1)));+        }+        let Some((_, upper, lower)) = row_cache.as_ref() else { continue };+        let Some(out) = data.get_mut(py * cw + x0..py * cw + x1) else { continue };+        for (slot, (gx, fx)) in out.iter_mut().zip(&cols) {+            let k = (gx - first) as usize;+            let (Some(&a), Some(&b), Some(&c), Some(&d)) = (upper.get(k), upper.get(k + 1), lower.get(k), lower.get(k + 1)) else { continue };+            let top = a + (b - a) * fx;+            let bottom = c + (d - c) * fx;+            if top + (bottom - top) * fy < limit {+                *slot = 255;+            }+        }+    }+    Some(Mask::from_parts(data, w, h))+}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@@ -16,6 +16,7 @@ mod extrude; mod light;  mod compound;+mod dissolve; mod extent; mod images; mod metafile;diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs--- a/crates/render/src/opts.rs+++ b/crates/render/src/opts.rs@@ -13,13 +13,25 @@ pub struct ShapeState {     pub clip: Option<[f64; 4]>,     /// Colour override for emphasis effects (fill).     pub tint: Option<Rgba>,+    /// Dissolve effects: the share (0..1) of the shape that shows, through noise fixed in slide space.+    pub dissolve: Option<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,+            dissolve: None,+            paras: vec![],+        }     } } diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs--- a/crates/render/src/renderer.rs+++ b/crates/render/src/renderer.rs@@ -348,7 +348,8 @@ impl Renderer {         }         // Footer placeholders on layouts/masters show only on slides that include them.         let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default();-        if !st.visible || st.opacity <= 0.001 {+        let dissolve = st.dissolve.filter(|d| *d < 0.999);+        if !st.visible || st.opacity <= 0.001 || dissolve.is_some_and(|d| d <= 0.001) {             return;         }         let x = resolve::xfrm(rctx, s);@@ -367,11 +368,23 @@ impl Renderer {         if let Some(a) = camera_turn(rctx, s, x.w, x.h) {             m *= a;         }-        let layered = st.opacity < 0.999 || st.clip.is_some();+        let layered = st.opacity < 0.999 || st.clip.is_some() || dissolve.is_some();         if layered {             ctx.set_transform(m);             let clip = st.clip.map(|[l, t, r, b]| Rect::new(l * x.w, t * x.h, r * x.w, b * x.h).to_path(0.1));-            ctx.push_layer(clip.as_ref(), None, Some(st.opacity.clamp(0.0, 1.0) as f32), None, None);+            let mut opacity = st.opacity.clamp(0.0, 1.0);+            // A dissolve shows part of the shape, wherever its noise is below the level: the area+            // is the whole canvas, as a shadow, reflection or overflowing text can fall anywhere.+            let mask = dissolve.and_then(|d| {+                let area = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()));+                let view = Affine::translate(f.opts.offset) * Affine::scale(f.opts.scale);+                let mask = dissolve::mask(ctx.width(), ctx.height(), area, view, 0x5DEE_CE66 ^ u64::from(s.id.0).wrapping_mul(0x9E37_79B9), d);+                if mask.is_none() {+                    opacity *= d;+                }+                mask+            });+            ctx.push_layer(clip.as_ref(), None, Some(opacity as f32), mask, None);         }         match &s.kind {             ShapeKind::Group { children, child } => {diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs--- a/crates/render/src/tests.rs+++ b/crates/render/src/tests.rs@@ -2951,3 +2951,92 @@ fn pie_data_labels_take_the_chart_text_colour_when_it_has_one() {     assert!(count(&coloured, &red) > 30, "the labels take the chart's red: {} red pixels", count(&coloured, &red));     assert!(count(&plain, &white) > count(&coloured, &white), "the labels were white before"); }++/// A black 400 x 300 pt box at (60, 60), drawn with a dissolve at `level` (None: no animation).+fn dissolved(level: Option<f64>, scale: f64) -> super::Image {+    let sh = Shape {+        id: ShapeId(700),+        xfrm: Some(Xfrm::new(60.0, 60.0, 400.0, 300.0)),+        fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 0, 0)))),+        line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),+        ..Default::default()+    };+    let p = deck_with(vec![sh]);+    let state = move |_: ShapeId| ShapeState { dissolve: level, ..Default::default() };+    render_slide(&p, 0, &RenderOpts { scale, state: Some(&state), ..Default::default() })+}++/// The pixels inside the box (60..460 x 60..360 pt, less a margin) that are black.+fn shown(img: &super::Image, scale: f64) -> Vec<bool> {+    let (x0, x1, y0, y1) = ((70.0 * scale) as u32, (450.0 * scale) as u32, (70.0 * scale) as u32, (350.0 * scale) as u32);+    (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0] < 128).collect()+}++#[test]+fn a_dissolve_shows_the_share_it_asks_for() {+    for level in [0.1, 0.3, 0.5, 0.7, 0.9] {+        let img = dissolved(Some(level), 1.0);+        let on = shown(&img, 1.0);+        let share = on.iter().filter(|b| **b).count() as f64 / on.len() as f64;+        assert!((share - level).abs() < 0.03, "level {level}: {share}");+        // Nothing outside the shape.+        assert_eq!(img.pixel(20, 20), [255, 255, 255, 255]);+        assert_eq!(img.pixel(600, 400), [255, 255, 255, 255]);+    }+    // Nothing at 0, all of it at 1, the same as no animation.+    assert!(shown(&dissolved(Some(0.0), 1.0), 1.0).iter().all(|b| !b));+    assert!(shown(&dissolved(Some(1.0), 1.0), 1.0).iter().all(|b| *b));+    assert_eq!(dissolved(Some(1.0), 1.0).pixel(200, 200), dissolved(None, 1.0).pixel(200, 200));+}++#[test]+fn what_a_dissolve_has_shown_stays_shown() {+    let sets: Vec<Vec<bool>> = [0.2, 0.4, 0.6, 0.8].iter().map(|l| shown(&dissolved(Some(*l), 1.0), 1.0)).collect();+    for pair in sets.windows(2) {+        assert!(pair[0].iter().zip(&pair[1]).all(|(a, b)| !*a || *b), "a pixel went away as the level grew");+        assert!(pair[1].iter().filter(|b| **b).count() > pair[0].iter().filter(|b| **b).count());+    }+}++#[test]+fn a_dissolve_is_the_same_pattern_at_any_scale() {+    // The noise sits in slide space: a 2x render, shrunk 2x, matches the 1x one (independent noise+    // would differ on about 4 pixels in 10).+    let (one, two) = (dissolved(Some(0.5), 1.0), dissolved(Some(0.5), 2.0));+    let mut diff = 0.0;+    let mut n = 0.0;+    for y in 70..350u32 {+        for x in 70..450u32 {+            let avg: f64 = (0..4).map(|i| two.pixel(2 * x + i % 2, 2 * y + i / 2)[0] as f64 / 255.0).sum::<f64>() / 4.0;+            diff += (one.pixel(x, y)[0] as f64 / 255.0 - avg).abs();+            n += 1.0;+        }+    }+    assert!(diff / n < 0.2, "mean difference {}", diff / n);+}++#[test]+fn a_dissolve_covers_what_the_shape_draws_far_from_its_box() {+    // A 40 pt box whose shadow falls 400 pt to its right, well past the room round the box: the+    // shadow dissolves with the shape instead of being cut off.+    let sh = Shape {+        id: ShapeId(701),+        xfrm: Some(Xfrm::new(20.0, 20.0, 40.0, 40.0)),+        fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 0, 0)))),+        line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),+        effects: Some(deckcraft_model::Effects { outer_shadow: Some(shadow(0.0, 400.0, 0.0)), ..Default::default() }),+        ..Default::default()+    };+    let p = deck_with(vec![sh]);+    let state = |_: ShapeId| ShapeState { dissolve: Some(0.9), ..Default::default() };+    let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, state: Some(&state), ..Default::default() });+    let (mut on, mut all) = (0, 0);+    for y in 22..58u32 {+        for x in 422..458u32 {+            all += 1;+            on += u32::from(img.pixel(x, y)[0] < 128);+        }+    }+    let share = f64::from(on) / f64::from(all);+    assert!((share - 0.9).abs() < 0.06, "the far shadow shows the dissolve's share: {share}");+}diff --git a/crates/ui-egui/src/canvas/preview.rs b/crates/ui-egui/src/canvas/preview.rs--- a/crates/ui-egui/src/canvas/preview.rs+++ b/crates/ui-egui/src/canvas/preview.rs@@ -20,12 +20,13 @@ pub fn shape_state(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeStat         rotate: a.rotate,         clip: a.clip,         tint: a.tint,+        dissolve: a.dissolve,         paras: tl             .paragraph_targets(id)             .into_iter()             .map(|p| {                 let a = tl.state(id, Some(p), step, t);-                (p, ParaState { visible: a.visible, opacity: a.opacity, offset: Vec2::new(a.offset_x, a.offset_y) })+                (p, ParaState { visible: a.visible, opacity: a.fade_opacity(), offset: Vec2::new(a.offset_x, a.offset_y) })             })             .collect(),     }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@@ -538,12 +538,13 @@ fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPl         rotate: a.rotate,         clip: a.clip,         tint: a.tint,+        dissolve: a.dissolve,         paras: tl             .paragraph_targets(id)             .into_iter()             .map(|p| {                 let a = tl.state(id, Some(p), step, t);-                (p, ParaState { visible: a.visible, opacity: a.opacity, offset: deckcraft_geom::Vec2::new(a.offset_x, a.offset_y) })+                (p, ParaState { visible: a.visible, opacity: a.fade_opacity(), offset: deckcraft_geom::Vec2::new(a.offset_x, a.offset_y) })             })             .collect(),     }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-22 · **Change:** minor (Animation Pane ▸ Play From plays from the selected effect; previous: 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-25 · **Change:** minor (Dissolve In and Out show the shape through fixed noise instead of fading; previous: minor, Animation Pane ▸ Play From plays from the selected effect; previous: 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,12 @@ 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 | +Dissolve In and Out show the shape through noise fixed in slide space, as PowerPoint draws them+(measured on its video export at 1080p and 4K): about 2 x 1.4 pt blobs that never go again once+shown, and an S-shaped share over the effect's whole duration (nothing before a tenth of the way,+all by nine tenths). Random Bars, Blinds, Checkerboard and Wheel entrances still play as plain+fades, and a by-paragraph build of text dissolves as a fade.+ 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 +100,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Dissolve In and Out: a noise mask fixed in slide space and PowerPoint's S-shaped reveal, in place of a fade. Counts unchanged | | 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h | | 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-16 | trivial | PPTX paragraph builds edited in PowerPoint keep their clicks; sub-points save With Previous of their parent (#844) |