From fae945831b147fc1d2f3bc8dcc6c7e55abecfa24 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:24:25 -0400 Subject: [PATCH] Transitions like PowerPoint: measured frame by frame and retuned Every transition and Effect Option was built in PowerPoint (COM), exported as video and compared frame by frame with DeckCraft at the same progress (mean abs diff over 150 variants 35.7 -> 15.0, 0-255). - anim: transition_frame(kind, option, t, aspect) with measured curves (smoothstep fades, ease-in-out moves, Cover/Uncover/Pan curves), directions and option meanings, soft edges as per-corner opacity, 3-D effects as perspective-projected cards, Dynamic Content moving slide contents over cross-fading backgrounds (Source::OldBack/NewBack/OldContent/NewContent); layer_mesh subdivides perspective quads so textures stay straight. - New options: Page Curl Single, Glitter Hexagons, Shred In/Out, Zoom and Rotate; PowerPoint-style Effect Options labels. - render: composite_transition (CPU twin of the show), render_slide_part, render_transition; deckcraft-cli render-transition FILE --slide N --t T. - show: per-vertex opacity meshes, slide parts rendered once per transition. - pptx: PowerPoint's elements and defaults (p:strips diagonal wipes, p14:warp and p:newsflash for Zoom, p:zoom for Shape In/Out, glitter pattern, shred rectangle/dir, prstTrans invX per preset, pageCurlSingle, defaults for randomBar/doors/window/pan/zoom); PowerPoint reopens DeckCraft's files with the same effects. - Tests: measured curves, probes of which slide shows where at t, option coverage, per-frame triangle budget, PowerPoint XML and defaults round trip. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + ROADMAP.md | 1 + apps/deckcraft-cli/src/main.rs | 77 ++ crates/anim/src/lib.rs | 8 +- crates/anim/src/mesh.rs | 139 ++++ crates/anim/src/transition.rs | 917 +++------------------ crates/anim/src/transition_fx.rs | 1169 +++++++++++++++++++++++++++ crates/anim/src/transition_geom.rs | 382 +++++++++ crates/anim/src/transition_tests.rs | 230 ++++++ crates/model/src/anim.rs | 129 ++- crates/pptx/src/maps.rs | 87 +- crates/pptx/tests/roundtrip.rs | 132 +++ crates/render/Cargo.toml | 1 + crates/render/src/lib.rs | 46 ++ crates/render/src/transition.rs | 517 ++++++++++++ crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/src/show.rs | 74 +- docs/animation-parity.md | 36 +- docs/architecture.md | 11 +- 19 files changed, 3080 insertions(+), 879 deletions(-) create mode 100644 crates/anim/src/mesh.rs create mode 100644 crates/anim/src/transition_fx.rs create mode 100644 crates/anim/src/transition_geom.rs create mode 100644 crates/anim/src/transition_tests.rs create mode 100644 crates/render/src/transition.rs diff --git a/Cargo.lock b/Cargo.lock index f4d5347..4044fce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1109,6 +1109,7 @@ dependencies = [ name = "deckcraft-render" version = "0.6.1" dependencies = [ + "deckcraft-anim", "deckcraft-color", "deckcraft-fonts", "deckcraft-geom", diff --git a/ROADMAP.md b/ROADMAP.md index 2dba0e2..26b2ec6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index c450393..fd1d96e 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -3,6 +3,7 @@ //! ```text //! deckcraft-cli info FILE # slides, titles, layouts (JSON) //! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR +//! deckcraft-cli render-transition FILE --slide N --t T[,T…] [--width PX] OUT.png # transition frame(s) //! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide) //! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print] //! deckcraft-cli commands [FILTER] # list commands (JSON) @@ -34,6 +35,7 @@ macro_rules! outln { const USAGE: &str = "usage: deckcraft-cli info FILE deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT + deckcraft-cli render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png deckcraft-cli convert IN OUT deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print] deckcraft-cli commands [FILTER] @@ -52,6 +54,7 @@ fn main() -> ExitCode { } Some("info") => report(info(rest)), Some("render") => report(render(rest)), + Some("render-transition") => report(render_transition(rest)), Some("convert") => report(convert(rest)), Some("run") => report(run(rest)), Some("commands") => { @@ -161,6 +164,80 @@ fn render(args: &[String]) -> Result<(), String> { Ok(()) } +/// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at +/// the given progress values. With several values, OUT gets `-t` before its extension. +fn render_transition(args: &[String]) -> Result<(), String> { + use deckcraft_engine::render; + let mut input = None; + let mut slide: Option = None; + let mut ts: Vec = Vec::new(); + let mut width: Option = None; + let mut out = None; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?), + "--t" => { + let v = it.next().ok_or("--t needs a value")?; + for x in v.split(',') { + let t: f64 = x.trim().parse().map_err(|_| format!("bad --t value `{x}`"))?; + if !t.is_finite() { + return Err(format!("bad --t value `{x}`")); + } + ts.push(t); + } + } + "--width" => { + width = Some(it.next().and_then(|v| v.parse().ok()).filter(|w: &f64| w.is_finite() && *w >= 1.0).ok_or("--width needs pixels")?) + } + other if input.is_none() => input = Some(other.to_string()), + other => out = Some(other.to_string()), + } + } + let input = input.ok_or("render-transition needs a FILE")?; + let out = out.ok_or("render-transition needs an output path")?; + let i = slide.ok_or("render-transition needs --slide N")?; + if ts.is_empty() { + return Err("render-transition needs --t".into()); + } + let mut s = Session::new(); + open(&mut s, Some(&input), false)?; + let st = s.doc().map_err(|e| e.to_string())?; + let doc = &st.doc; + let n = doc.slides.len(); + let Some(sl) = doc.slides.get(i) else { return Err(format!("no slide {i} (the deck has {n})")) }; + let sw = doc.slide_size.width.max(1.0); + let w = width.unwrap_or(sw * 2.0).min(render::MAX_SIDE as f64); + let opts = render::RenderOpts { scale: w / sw, ..Default::default() }; + let tr = sl.transition.clone().unwrap_or_default(); + // Render both slides once and composite every frame from them (morph re-renders per frame). + let prev = i.checked_sub(1); + let cached = (tr.kind != "morph").then(|| { + let parts = render::uses_slide_parts(&tr.kind, &tr.option).then(|| render::SlideParts::render(doc, prev, i, &opts)); + (prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts) + }); + let aspect = sw / doc.slide_size.height.max(1.0); + for t in &ts { + let img = match &cached { + Some((old, new, parts)) => { + render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect)) + } + None => render::render_transition(doc, i, *t, &opts), + }; + let path = if ts.len() == 1 { + out.clone() + } else { + let p = std::path::Path::new(&out); + let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame"); + let name = format!("{stem}-t{t:.3}.png"); + p.with_file_name(name).to_string_lossy().into_owned() + }; + std::fs::write(&path, img.to_png()).map_err(|e| format!("{path}: {e}"))?; + outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option); + } + Ok(()) +} + fn convert(args: &[String]) -> Result<(), String> { let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) }; let mut s = Session::new(); diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index fae7e4d..d94167c 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -14,24 +14,30 @@ mod easing; mod effects; +mod mesh; mod morph; mod path; mod show; mod timeline; mod transition; +mod transition_fx; +mod transition_geom; #[cfg(test)] mod tests; +#[cfg(test)] +mod transition_tests; pub use deckcraft_model::Rgba; use serde::{Deserialize, Serialize}; pub use easing::ease; +pub use mesh::{MeshVertex, layer_mesh}; pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; -pub use transition::{Layer, Source, transition_layers}; +pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts}; /// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show. /// diff --git a/crates/anim/src/mesh.rs b/crates/anim/src/mesh.rs new file mode 100644 index 0000000..fc68838 --- /dev/null +++ b/crates/anim/src/mesh.rs @@ -0,0 +1,139 @@ +//! Triangle meshes for transition layers. +//! +//! A [`Layer`] is a textured quad. Drawn as two triangles, a trapezoid (a slide seen in +//! perspective) bends its texture along the diagonal, because GPUs and simple rasterisers +//! interpolate texture coordinates linearly in screen space. [`layer_mesh`] splits such quads into a +//! grid whose vertices follow the projective map from the source rectangle to the quad, so the +//! picture looks like a flat card turned in 3-D. Rectangles and parallelograms stay two triangles, +//! wedges one: the per-frame cost stays a handful of vertices per layer. + +use crate::transition::Layer; + +/// One mesh vertex: position in slide-unit space (0..1, y down), texture coordinate (0..1) and +/// opacity (the layer's alpha times its corner fade, interpolated). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshVertex { + pub pos: [f64; 2], + pub uv: [f64; 2], + pub a: f64, +} + +/// Grid cells per side for quads that need a projective subdivision. +const GRID: usize = 8; + +/// The triangles of a layer: vertices and index triples into them. +pub fn layer_mesh(l: &Layer) -> (Vec, Vec<[u32; 3]>) { + let q = l.quad; + let uv = l.uv; + let fa = l.fade.map(|f| f * l.alpha); + // A wedge (triangle): the 4th corner repeats the 3rd. + if q[3] == q[2] && uv[3] == uv[2] { + let v = (0..3).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect(); + return (v, vec![[0, 1, 2]]); + } + let affine = { + // Parallelogram: opposite edges equal. + let e1 = [q[1][0] - q[0][0], q[1][1] - q[0][1]]; + let e2 = [q[2][0] - q[3][0], q[2][1] - q[3][1]]; + (e1[0] - e2[0]).abs() < 1e-9 && (e1[1] - e2[1]).abs() < 1e-9 + }; + let h = if affine { None } else { square_to_quad(q) }; + let Some(h) = h else { + let v = (0..4).map(|k| MeshVertex { pos: q[k], uv: uv[k], a: fa[k] }).collect(); + return (v, vec![[0, 1, 2], [0, 2, 3]]); + }; + // Small quads (tiles, shards) bend their texture too little to matter: fewer cells. + let span = q.iter().flat_map(|p| q.iter().map(move |r| (p[0] - r[0]).abs().max((p[1] - r[1]).abs()))).fold(0.0, f64::max); + let n = ((span * 2.0 * GRID as f64).ceil() as usize).clamp(1, GRID); + let mut v = Vec::with_capacity((n + 1) * (n + 1)); + for j in 0..=n { + let b = j as f64 / n as f64; + for i in 0..=n { + let a = i as f64 / n as f64; + // Texture: bilinear in (a, b) over the four uv corners (exact for rectangles). + let top = lerp2(uv[0], uv[1], a); + let bot = lerp2(uv[3], uv[2], a); + let t = lerp2(top, bot, b); + let at = fa[0] + (fa[1] - fa[0]) * a; + let ab = fa[3] + (fa[2] - fa[3]) * a; + v.push(MeshVertex { pos: apply(&h, a, b), uv: t, a: at + (ab - at) * b }); + } + } + let mut tris = Vec::with_capacity(n * n * 2); + let w = (n + 1) as u32; + for j in 0..n as u32 { + for i in 0..n as u32 { + let p = j * w + i; + tris.push([p, p + 1, p + w + 1]); + tris.push([p, p + w + 1, p + w]); + } + } + (v, tris) +} + +fn lerp2(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] { + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] +} + +/// Projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`'s tl, tr, br, +/// bl (Heckbert's square-to-quad). `None` when the quad is degenerate. +fn square_to_quad(q: [[f64; 2]; 4]) -> Option<[f64; 8]> { + let [[x0, y0], [x1, y1], [x2, y2], [x3, y3]] = q; + let sx = x0 - x1 + x2 - x3; + let sy = y0 - y1 + y2 - y3; + let (dx1, dx2, dy1, dy2) = (x1 - x2, x3 - x2, y1 - y2, y3 - y2); + let den = dx1 * dy2 - dx2 * dy1; + if !den.is_finite() || den.abs() < 1e-12 { + return None; + } + let g = (sx * dy2 - dx2 * sy) / den; + let h = (dx1 * sy - sx * dy1) / den; + let m = [x1 - x0 + g * x1, x3 - x0 + h * x3, x0, y1 - y0 + g * y1, y3 - y0 + h * y3, y0, g, h]; + m.iter().all(|v| v.is_finite()).then_some(m) +} + +fn apply(m: &[f64; 8], u: f64, v: f64) -> [f64; 2] { + let w = m[6] * u + m[7] * v + 1.0; + let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w }; + [(m[0] * u + m[1] * v + m[2]) / w, (m[3] * u + m[4] * v + m[5]) / w] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transition::{Layer, Source}; + + #[test] + fn rect_is_two_triangles() { + let (v, t) = layer_mesh(&Layer::full(Source::New, 1.0)); + assert_eq!((v.len(), t.len()), (4, 2)); + } + + #[test] + fn trapezoid_is_subdivided_projectively() { + // A card turned about its left edge: the right edge is shorter. + let q = [[0.0, 0.0], [0.6, 0.2], [0.6, 0.8], [0.0, 1.0]]; + let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0); + let (v, t) = layer_mesh(&l); + assert_eq!(v.len(), (GRID + 1) * (GRID + 1)); + assert_eq!(t.len(), GRID * GRID * 2); + // A small tile in perspective needs no more than a couple of triangles. + let small = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], [[0.5, 0.5], [0.52, 0.505], [0.52, 0.515], [0.5, 0.52]], 1.0); + assert!(layer_mesh(&small).1.len() <= 2); + // Corners land on the quad's corners. + assert_eq!(v[0].pos, q[0]); + let last = v[v.len() - 1].pos; + assert!((last[0] - 0.6).abs() < 1e-9 && (last[1] - 0.8).abs() < 1e-9); + // The texture's middle column sits nearer the far (short) edge than halfway: foreshortening. + let mid = v[GRID / 2].pos[0]; + assert!(mid > 0.3, "{mid}"); + } + + #[test] + fn degenerate_quads_do_not_blow_up() { + let q = [[0.5, 0.5]; 4]; + let l = Layer::quad(Source::Old, [0.0, 0.0, 1.0, 1.0], q, 1.0); + let (v, _) = layer_mesh(&l); + assert!(v.iter().all(|p| p.pos[0].is_finite() && p.pos[1].is_finite())); + } +} diff --git a/crates/anim/src/transition.rs b/crates/anim/src/transition.rs index 0289246..7369ae9 100644 --- a/crates/anim/src/transition.rs +++ b/crates/anim/src/transition.rs @@ -1,15 +1,18 @@ //! Slide transitions as layers of textured quads. //! //! A frame is drawn over black: each [`Layer`] maps part of a source image (old slide, new slide, -//! or a solid colour) onto a quad in slide-unit space (0..1, y down). Fake 3-D comes from -//! trapezoids; wedges are triangles (4th corner repeats the 3rd). - -use std::f64::consts::PI; +//! their backgrounds or contents alone, or a solid colour) onto a quad in slide-unit space (0..1, y +//! down), with an opacity per corner. Perspective comes from projecting 3-D cards +//! ([`crate::layer_mesh`] keeps their textures straight); soft edges come from per-corner opacity +//! ramps; wedges are triangles (4th corner repeats the 3rd). +//! +//! The motions, curves, directions and option meanings were measured frame by frame on +//! PowerPoint's video export of synthetic decks (`plan/powerpoint/tools/synth_transitions.ps1`). +//! The effects themselves live in [`crate::transition_fx`]. use serde::{Deserialize, Serialize}; use crate::clampf; -use crate::easing::smooth; /// What a layer shows. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -20,6 +23,35 @@ pub enum Source { New, Black, White, + /// The outgoing slide's background (background fill plus master and layout graphics) alone. + OldBack, + /// The incoming slide's background alone. + NewBack, + /// The outgoing slide's own shapes alone, over transparency. + OldContent, + /// The incoming slide's own shapes alone, over transparency. + NewContent, +} + +impl Source { + /// The whole-slide source to draw instead when the parts aren't available: a background + /// becomes its slide, content becomes nothing. + pub fn whole(self) -> Option { + match self { + Source::OldBack => Some(Source::Old), + Source::NewBack => Some(Source::New), + Source::OldContent | Source::NewContent => None, + s => Some(s), + } + } + /// Does this source need the slide rendered in parts (background / content)? + pub fn is_part(self) -> bool { + matches!(self, Source::OldBack | Source::NewBack | Source::OldContent | Source::NewContent) + } +} + +fn ones() -> [f64; 4] { + [1.0; 4] } /// One textured quad of a transition frame. @@ -34,12 +66,18 @@ pub struct Layer { /// are the corners of `src`; triangles and mirrored flaps carry their own. Draw with these. pub uv: [[f64; 2]; 4], pub alpha: f64, + /// Opacity of each corner (tl, tr, br, bl), multiplied by `alpha` and interpolated across the + /// quad: soft edges and feathered masks. + #[serde(default = "ones")] + pub fade: [f64; 4], } -type R = [f64; 4]; -const FULL: R = [0.0, 0.0, 1.0, 1.0]; +pub(crate) type R = [f64; 4]; +pub(crate) type P = [f64; 2]; +pub(crate) type Q = [[f64; 2]; 4]; +pub(crate) const FULL: R = [0.0, 0.0, 1.0, 1.0]; -fn corners(r: R) -> [[f64; 2]; 4] { +pub(crate) fn corners(r: R) -> Q { [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] } @@ -50,23 +88,39 @@ impl Layer { } /// `src` rectangle of the source drawn into the `dst` rectangle. pub fn rect(source: Source, src: R, dst: R, alpha: f64) -> Layer { - Layer { source, src, quad: corners(dst), uv: corners(src), alpha } + Layer { source, src, quad: corners(dst), uv: corners(src), alpha, fade: ones() } } /// `src` rectangle of the source drawn onto a quad. - pub fn quad(source: Source, src: R, quad: [[f64; 2]; 4], alpha: f64) -> Layer { - Layer { source, src, quad, uv: corners(src), alpha } + pub fn quad(source: Source, src: R, quad: Q, alpha: f64) -> Layer { + Layer { source, src, quad, uv: corners(src), alpha, fade: ones() } } /// The part of the source under `dst` drawn in place. - fn inplace(source: Source, dst: R, alpha: f64) -> Layer { + pub(crate) fn inplace(source: Source, dst: R, alpha: f64) -> Layer { Layer::rect(source, dst, dst, alpha) } - /// A triangle of the source drawn in place. - fn tri(source: Source, a: [f64; 2], b: [f64; 2], c: [f64; 2], alpha: f64) -> Layer { - let xs = [a[0], b[0], c[0]]; - let ys = [a[1], b[1], c[1]]; + /// A triangle with its own texture coordinates and corner opacities. + pub(crate) fn tri_uv(source: Source, p: [P; 3], uv: [P; 3], fade: [f64; 3], alpha: f64) -> Layer { + let xs = [uv[0][0], uv[1][0], uv[2][0]]; + let ys = [uv[0][1], uv[1][1], uv[2][1]]; let mn = |v: [f64; 3]| v.iter().copied().fold(f64::INFINITY, f64::min); let mx = |v: [f64; 3]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max); - Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: [a, b, c, c], uv: [a, b, c, c], alpha } + Layer { + source, + src: [mn(xs), mn(ys), mx(xs), mx(ys)], + quad: [p[0], p[1], p[2], p[2]], + uv: [uv[0], uv[1], uv[2], uv[2]], + alpha, + fade: [fade[0], fade[1], fade[2], fade[2]], + } + } + /// With corner opacities. + pub(crate) fn faded(mut self, fade: [f64; 4]) -> Layer { + self.fade = fade; + self + } + /// A black copy of this layer's shape at `k` of its opacity: shading a card. + pub(crate) fn shade(&self, k: f64) -> Layer { + Layer { source: Source::Black, alpha: self.alpha * k.clamp(0.0, 1.0), ..*self } } fn sanitized(mut self) -> Layer { let f = |v: f64| if v.is_finite() { v.clamp(-1.0e6, 1.0e6) } else { 0.0 }; @@ -81,827 +135,48 @@ impl Layer { c[0] = c[0].clamp(0.0, 1.0); c[1] = c[1].clamp(0.0, 1.0); } + for a in self.fade.iter_mut() { + *a = clampf(*a, 0.0, 1.0, 0.0); + } self.alpha = clampf(self.alpha, 0.0, 1.0, 0.0); self } } -// ---------- geometry helpers ---------- - -fn offset(r: R, dx: f64, dy: f64) -> R { - [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy] -} - -fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer { - Layer::rect(source, FULL, offset(FULL, dx, dy), alpha) -} - -/// The whole source scaled about (cx, cy) and moved. -fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer { - let r = [cx + (0.0 - cx) * s + dx, cy + (0.0 - cy) * s + dy, cx + (1.0 - cx) * s + dx, cy + (1.0 - cy) * s + dy]; - Layer::rect(source, FULL, r, alpha) -} - -/// Rotate a point about a pivot (degrees). -fn rot(p: [f64; 2], c: [f64; 2], deg: f64) -> [f64; 2] { - let (s, co) = deg.to_radians().sin_cos(); - let (x, y) = (p[0] - c[0], p[1] - c[1]); - [c[0] + x * co - y * s, c[1] + x * s + y * co] -} - -fn rot_quad(q: [[f64; 2]; 4], c: [f64; 2], deg: f64) -> [[f64; 2]; 4] { - [rot(q[0], c, deg), rot(q[1], c, deg), rot(q[2], c, deg), rot(q[3], c, deg)] -} - -/// Horizontal perspective: left/right edges shortened by `li`/`ri` (fraction of height, each end). -fn persp_h(r: R, li: f64, ri: f64) -> [[f64; 2]; 4] { - let h = r[3] - r[1]; - [[r[0], r[1] + li * h], [r[2], r[1] + ri * h], [r[2], r[3] - ri * h], [r[0], r[3] - li * h]] -} - -/// Vertical perspective: top/bottom edges shortened by `ti`/`bi` (fraction of width, each end). -fn persp_v(r: R, ti: f64, bi: f64) -> [[f64; 2]; 4] { - let w = r[2] - r[0]; - [[r[0] + ti * w, r[1]], [r[2] - ti * w, r[1]], [r[2] - bi * w, r[3]], [r[0] + bi * w, r[3]]] -} - -/// Mirror a rectangle horizontally and/or vertically, or swap its axes (canonical → direction). -fn map_rect(r: R, mx: bool, my: bool, swap: bool) -> R { - let mut r = if swap { [r[1], r[0], r[3], r[2]] } else { r }; - if mx { - r = [1.0 - r[2], r[1], 1.0 - r[0], r[3]]; - } - if my { - r = [r[0], 1.0 - r[3], r[2], 1.0 - r[1]]; - } - r -} - -/// Motion direction of an option: l, r, u, d and diagonals. -fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) { - let mut d = (0.0, 0.0); - if opt.contains('l') { - d.0 = -1.0; - } - if opt.contains('r') { - d.0 = 1.0; - } - if opt.contains('u') { - d.1 = -1.0; - } - if opt.contains('d') { - d.1 = 1.0; - } - if d == (0.0, 0.0) { default } else { d } -} - -fn hash64(mut x: u64) -> u64 { - x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); - x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - x ^ (x >> 31) -} - -/// Deterministic pseudo-random value in [0, 1). -fn rnd(i: u64) -> f64 { - (hash64(i) >> 11) as f64 / (1u64 << 53) as f64 -} - -fn ramp(t: f64, order: f64, window: f64) -> f64 { - let w = window.clamp(0.01, 1.0); - ((t - order * (1.0 - w)) / w).clamp(0.0, 1.0) -} - -fn grid(cols: usize, rows: usize) -> Vec<(usize, usize, R)> { - let mut v = Vec::with_capacity(cols * rows); - for r in 0..rows { - for c in 0..cols { - let cell = [c as f64 / cols as f64, r as f64 / rows as f64, (c + 1) as f64 / cols as f64, (r + 1) as f64 / rows as f64]; - v.push((c, r, cell)); - } - } - v -} - -fn center(r: R) -> [f64; 2] { - [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0] -} - -fn scale_rect(r: R, s: f64) -> R { - let c = center(r); - let hw = (r[2] - r[0]) / 2.0 * s; - let hh = (r[3] - r[1]) / 2.0 * s; - [c[0] - hw, c[1] - hh, c[0] + hw, c[1] + hh] -} - -// ---------- effects ---------- +/// Slide aspect assumed by [`transition_layers`] (16:9). +pub const DEFAULT_ASPECT: f64 = 16.0 / 9.0; /// Layers of the transition `kind` (a [`deckcraft_model::anim::TRANSITIONS`] id) with `option` at -/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone; -/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph -/// frames with [`crate::morph_frame`]). +/// progress `t` (0..1) on a 16:9 slide. See [`transition_frame`]. pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec { + transition_frame(kind, option, t, DEFAULT_ASPECT) +} + +/// Layers of the transition `kind` with `option` at linear progress `t` (0..1; the effect applies +/// its own easing) on a slide `aspect` wide per unit of height. Draw in order over black. At +/// `t >= 1` the result is the new slide alone; at `t <= 0` (or NaN) the old slide alone. Morph +/// returns a plain crossfade (the UI renders morph frames with [`crate::morph_frame`]). Layers +/// with [`Source::is_part`] sources need the slides rendered as background and content +/// ([`uses_slide_parts`]). +pub fn transition_frame(kind: &str, option: &str, t: f64, aspect: f64) -> Vec { let t = clampf(t, 0.0, 1.0, 0.0); + let aspect = if aspect.is_finite() && aspect > 0.01 { aspect.min(100.0) } else { DEFAULT_ASPECT }; let v = if t >= 1.0 { vec![Layer::full(Source::New, 1.0)] } else if t <= 0.0 { vec![Layer::full(Source::Old, 1.0)] } else { - layers(kind, option, t, 0) + crate::transition_fx::layers(kind, option, t, aspect, 0) }; - v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0).collect() + v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0 && l.fade.iter().any(|a| *a > 0.0)).collect() } -fn crossfade(t: f64) -> Vec { - vec![Layer::full(Source::Old, 1.0), Layer::full(Source::New, t)] -} - -const RANDOM_KINDS: &[&str] = &[ - "fade", - "push", - "wipe", - "split", - "reveal", - "randomBar", - "shape", - "uncover", - "cover", - "fallOver", - "drape", - "curtains", - "wind", - "prestige", - "fracture", - "crush", - "peelOff", - "pageCurlDouble", - "airplane", - "origami", - "dissolve", - "checker", - "blinds", - "clock", - "ripple", - "honeycomb", - "glitter", - "vortex", - "shred", - "switch", - "flip", - "gallery", - "cube", - "doors", - "box", - "comb", - "zoom", - "pan", - "ferris", - "conveyor", - "rotate", - "window", - "orbit", - "flythrough", -]; - -fn layers(kind: &str, option: &str, t: f64, depth: u32) -> Vec { - use Source::{Black, New, Old, White}; - let o = option; - let s = smooth(t); +/// Does `kind` animate the slides' backgrounds and contents separately (PowerPoint's Dynamic +/// Content transitions and Reveal)? Then the frame uses [`Source::OldBack`] & co. +pub fn uses_slide_parts(kind: &str, option: &str) -> bool { match kind { - "none" => vec![Layer::full(New, 1.0)], - "cut" => { - if o == "throughBlack" && t < 0.5 { - vec![Layer::full(Black, 1.0)] - } else { - vec![Layer::full(New, 1.0)] - } - } - "fade" if o == "throughBlack" => { - if t < 0.5 { - vec![Layer::full(Black, 1.0), Layer::full(Old, 1.0 - 2.0 * t)] - } else { - vec![Layer::full(Black, 1.0), Layer::full(New, 2.0 * t - 1.0)] - } - } - "fade" | "morph" => crossfade(t), - "push" | "pan" => { - let (dx, dy) = dir(o, (0.0, -1.0)); - vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] - } - "uncover" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)] - } - "cover" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] - } - "wipe" => wipe(o, t), - "split" => { - let horz = o.starts_with("horz"); - let mut v = vec![Layer::full(Old, 1.0)]; - let rects: Vec = if o.ends_with("In") { - vec![[0.0, 0.0, s / 2.0, 1.0], [1.0 - s / 2.0, 0.0, 1.0, 1.0]] - } else { - vec![[0.5 - s / 2.0, 0.0, 0.5 + s / 2.0, 1.0]] - }; - for r in rects { - v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); - } - v - } - "reveal" => { - let dx = if o.ends_with("Right") { 1.0 } else { -1.0 }; - if o.starts_with("black") { - if t < 0.5 { - vec![Layer::full(Black, 1.0), moved(Old, dx * t * 0.4, 0.0, 1.0 - 2.0 * t)] - } else { - vec![Layer::full(Black, 1.0), moved(New, -dx * (1.0 - t) * 0.2, 0.0, 2.0 * t - 1.0)] - } - } else { - vec![Layer::full(New, 1.0), moved(Old, dx * s * 0.6, 0.0, 1.0 - t)] - } - } - "randomBar" => { - let horz = o == "horz"; - let n = 48; - let mut v = vec![Layer::full(Old, 1.0)]; - for i in 0..n { - if t > rnd(i as u64 + 17) * 0.98 { - let r = [i as f64 / n as f64, 0.0, (i + 1) as f64 / n as f64, 1.0]; - v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); - } - } - v - } - "shape" => { - let form = match o { - "circle" | "diamond" | "plus" => o, - _ => "box", - }; - let inward = o == "in"; - if inward { - let mut v = vec![Layer::full(New, 1.0)]; - v.extend(shape_form(Old, form, 1.0 - s)); - v - } else { - let mut v = vec![Layer::full(Old, 1.0)]; - v.extend(shape_form(New, form, s)); - v - } - } - "flash" => { - if t < 0.5 { - vec![Layer::full(Old, 1.0), Layer::full(White, (2.0 * t).min(1.0))] - } else { - vec![Layer::full(New, 1.0), Layer::full(White, 2.0 - 2.0 * t)] - } - } - "fallOver" => { - let sg = if o == "r" { 1.0 } else { -1.0 }; - let a = t * t * PI / 2.0; - let top = 1.0 - a.cos(); - let ins = 0.12 * a.sin(); - let sh = sg * 0.15 * a.sin(); - let q = [[ins + sh, top], [1.0 - ins + sh, top], [1.0, 1.0], [0.0, 1.0]]; - vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t.powi(3))] - } - "drape" => { - let k = t * t; - let q = [[0.0, 0.0], [1.0 - 0.3 * k, k], [1.0 - 0.3 * k, 1.0 + k], [0.0, 1.0]]; - let q = if o == "r" { mirror_q(q) } else { q }; - vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - k)] - } - "curtains" => { - let w = 0.5 * (1.0 - s); - let lq = [[0.0, 0.0], [w, 0.12 * s], [w, 1.0 - 0.12 * s], [0.0, 1.0]]; - let rq = [[1.0 - w, 0.12 * s], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 - 0.12 * s]]; - vec![ - scaled(New, 0.9 + 0.1 * s, 0.5, 0.5, 0.0, 0.0, 1.0), - Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], lq, 1.0), - Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], rq, 1.0), - ] - } - "wind" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let r = offset(FULL, d * 1.2 * s, -0.1 * s); - let q = [[r[0] + d * 0.2 * s, r[1]], [r[2] + d * 0.2 * s, r[1]], [r[2], r[3]], [r[0], r[3]]]; - vec![moved(New, -d * 0.3 * (1.0 - s), 0.0, smooth((t * 1.5).min(1.0))), Layer::quad(Old, FULL, q, 1.0 - t)] - } - "prestige" => vec![scaled(Old, 1.0 - 0.3 * s, 0.5, 0.5, 0.0, 0.2 * s, 1.0 - t), scaled(New, 0.7 + 0.3 * s, 0.5, 0.5, 0.0, 0.0, s)], - "fracture" => { - let mut v = vec![Layer::full(New, 1.0)]; - for (c, r, cell) in grid(8, 6) { - let i = (r * 8 + c) as u64; - let q = ramp(t, rnd(i + 101) * 0.5, 0.5); - let cc = center(cell); - let (dx, dy) = ((cc[0] - 0.5) * 1.5 * q, (cc[1] - 0.5) * 1.5 * q + 0.5 * q * q); - let quad = rot_quad(corners(offset(cell, dx, dy)), [cc[0] + dx, cc[1] + dy], (rnd(i + 7) - 0.5) * 180.0 * q); - v.push(Layer::quad(Old, cell, quad, 1.0 - q)); - } - v - } - "crush" => { - let mut v = vec![Layer::full(New, 1.0)]; - for (c, r, cell) in grid(4, 4) { - let i = (r * 4 + c) as u64; - let cc = center(cell); - let k = 1.0 - s; - let pos = [0.5 + (cc[0] - 0.5) * k, 0.5 + (cc[1] - 0.5) * k]; - let half = scale_rect(cell, k * (0.8 + 0.2 * rnd(i + 3))); - let half = offset(half, pos[0] - cc[0], pos[1] - cc[1]); - let quad = rot_quad(corners(half), pos, (rnd(i + 5) - 0.5) * 60.0 * s); - v.push(Layer::quad(Old, cell, quad, 1.0 - t * t)); - } - v - } - "peelOff" => { - let mut v = vec![Layer::full(New, 1.0)]; - v.extend(peel(Old, [0.0, 0.0, 1.0, 1.0], s, o == "r")); - v - } - "pageCurlDouble" => { - let mut v = vec![Layer::full(New, 1.0)]; - let left_first = o != "r"; - v.extend(peel(Old, [0.0, 0.0, 0.5, 1.0], s, !left_first)); - v.extend(peel(Old, [0.5, 0.0, 1.0, 1.0], s, left_first)); - v - } - "airplane" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let k = 1.0 - 0.8 * s; - let r = offset(scale_rect(FULL, k), d * 1.2 * s * s, -0.5 * s * s); - let q = persp_h(r, if d > 0.0 { 0.3 * s } else { 0.0 }, if d > 0.0 { 0.0 } else { 0.3 * s }); - vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t * t)] - } - "origami" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let mut v = vec![Layer::full(New, 1.0)]; - if t < 0.5 { - let f = t * 2.0; - let w = 0.5 * (1.0 - 0.8 * f); - v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], persp_h([0.5 - w, 0.0, 0.5, 1.0], 0.15 * f, 0.0), 1.0)); - v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], persp_h([0.5, 0.0, 0.5 + w, 1.0], 0.0, 0.15 * f), 1.0)); - } else { - let f = (t - 0.5) * 2.0; - let r = offset(scale_rect([0.4, 0.0, 0.6, 1.0], 1.0 - 0.6 * f), d * f * 0.8, -0.3 * f); - v.push(Layer::quad(Old, FULL, persp_v(r, 0.1, 0.0), 1.0 - f)); - } - v - } - "dissolve" => { - let mut v = vec![Layer::full(Old, 1.0)]; - for (c, r, cell) in grid(32, 18) { - if t > rnd((r * 32 + c) as u64 + 991) * 0.98 { - v.push(Layer::inplace(New, cell, 1.0)); - } - } - v - } - "checker" => { - let vert = o == "vert"; - let (n, m) = (10usize, 8usize); - let mut v = vec![Layer::full(Old, 1.0)]; - for (c, r, cell) in grid(n, m) { - let across = if vert { c } else { r }; - let f = (t * 2.0 - (across % 2) as f64 * 0.5).clamp(0.0, 1.0); - if f <= 0.0 { - continue; - } - let rc = if vert { - [cell[0], cell[1], cell[2], cell[1] + (cell[3] - cell[1]) * f] - } else { - [cell[0], cell[1], cell[0] + (cell[2] - cell[0]) * f, cell[3]] - }; - v.push(Layer::inplace(New, rc, 1.0)); - } - v - } - "blinds" => { - let horz = o == "horz"; - let n = 8; - let mut v = vec![Layer::full(Old, 1.0)]; - for i in 0..n { - let x0 = i as f64 / n as f64; - let r = [x0, 0.0, x0 + s / n as f64, 1.0]; - v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); - } - v - } - "clock" => clock(o, s), - "ripple" => { - let origin = match o { - "lu" => [0.0, 0.0], - "ru" => [1.0, 0.0], - "ld" => [0.0, 1.0], - "rd" => [1.0, 1.0], - _ => [0.5, 0.5], - }; - let maxd = if origin == [0.5, 0.5] { 0.71 } else { 1.42 }; - let mut v = vec![Layer::full(Old, 1.0)]; - for (_, _, cell) in grid(24, 14) { - let cc = center(cell); - let d = ((cc[0] - origin[0]).powi(2) + (cc[1] - origin[1]).powi(2)).sqrt() / maxd; - let a = ramp(t, d.min(1.0), 0.3); - if a > 0.0 { - let sc = 1.0 + 0.15 * (a * PI).sin(); - v.push(Layer::rect(New, cell, scale_rect(cell, sc), a)); - } - } - v - } - "honeycomb" => { - let (cols, rows) = (12usize, 8usize); - let mut v = vec![Layer::full(Old, 1.0)]; - for c in 0..cols { - let odd = c % 2 == 1; - let extra = if odd { 1 } else { 0 }; - for r in 0..rows + extra { - let y0 = (r as f64 - if odd { 0.5 } else { 0.0 }) / rows as f64; - let cell = [c as f64 / cols as f64, y0.max(0.0), (c + 1) as f64 / cols as f64, (y0 + 1.0 / rows as f64).min(1.0)]; - let a = ramp(t, rnd((c * 64 + r) as u64 + 4242), 0.35); - if a > 0.0 { - v.push(Layer::rect(New, cell, scale_rect(cell, 0.3 + 0.7 * a), a)); - } - } - } - v - } - "glitter" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - let mut v = vec![Layer::full(Old, 1.0)]; - for (c, r, cell) in grid(20, 12) { - let cc = center(cell); - let grad = if dx < 0.0 { - 1.0 - cc[0] - } else if dx > 0.0 { - cc[0] - } else if dy < 0.0 { - 1.0 - cc[1] - } else { - cc[1] - }; - let order = 0.6 * grad + 0.4 * rnd((r * 20 + c) as u64 + 77); - let a = ramp(t, order, 0.25); - if a > 0.0 { - let quad = rot_quad(corners(cell), cc, (1.0 - a) * 90.0); - v.push(Layer::quad(New, cell, quad, a)); - } - } - v - } - "vortex" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - let mut v = vec![Layer::full(New, 1.0)]; - for (c, r, cell) in grid(16, 9) { - let i = (r * 16 + c) as u64; - let cc = center(cell); - let grad = 0.5 + 0.5 * ((cc[0] - 0.5) * -dx + (cc[1] - 0.5) * -dy); - let q = ramp(t, 0.5 * grad + 0.5 * rnd(i + 31), 0.4); - let ang = q * 2.0 * PI + rnd(i) * PI; - let rad = q * 1.2; - let (ox, oy) = (dx * q * 0.8 + rad * ang.cos() * 0.3, dy * q * 0.8 + rad * ang.sin() * 0.3); - let pos = [cc[0] + ox, cc[1] + oy]; - let quad = rot_quad(corners(offset(scale_rect(cell, 1.0 - 0.5 * q), ox, oy)), pos, q * 360.0); - v.push(Layer::quad(Old, cell, quad, 1.0 - q)); - } - v - } - "shred" => { - let mut v = vec![Layer::full(New, 1.0)]; - if o == "particles" { - for (c, r, cell) in grid(24, 14) { - let i = (r * 24 + c) as u64; - let q = ramp(t, rnd(i + 555) * 0.6 + 0.4 * (1.0 - center(cell)[1]), 0.4); - let dy = q * q * 1.2; - let dx = (rnd(i + 9) - 0.5) * 0.3 * q; - v.push(Layer::rect(Old, cell, offset(scale_rect(cell, 1.0 - 0.6 * q), dx, dy), 1.0 - q)); - } - } else { - let n = 16; - for i in 0..n { - let x0 = i as f64 / n as f64; - let x1 = (i + 1) as f64 / n as f64; - let q = ramp(t, i as f64 / n as f64 * 0.3 + 0.1 * rnd(i as u64), 0.7); - let dy = if i % 2 == 0 { 1.1 * q * q } else { -1.1 * q * q }; - v.push(Layer::rect(Old, [x0, 0.0, x1, 1.0], [x0, dy, x1, 1.0 + dy], 1.0)); - } - } - v - } - "switch" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let k = (t * PI).sin(); - let old = Layer::quad( - Old, - FULL, - persp_h( - offset(scale_rect(FULL, 1.0 - 0.25 * t), d * 0.45 * k, 0.0), - if d > 0.0 { 0.0 } else { 0.08 * k }, - if d > 0.0 { 0.08 * k } else { 0.0 }, - ), - 1.0, - ); - let new = Layer::quad( - New, - FULL, - persp_h( - offset(scale_rect(FULL, 0.75 + 0.25 * t), -d * 0.45 * k, 0.0), - if d > 0.0 { 0.08 * k } else { 0.0 }, - if d > 0.0 { 0.0 } else { 0.08 * k }, - ), - 1.0, - ); - if t < 0.5 { vec![new, old] } else { vec![old, new] } - } - "flip" => { - let a = PI * s; - let w = a.cos().abs(); - let k = 0.1 * a.sin(); - let r = [0.5 - w / 2.0, 0.0, 0.5 + w / 2.0, 1.0]; - let right = o != "l"; - if t < 0.5 { - let q = if right { persp_h(r, -k / 2.0, k) } else { persp_h(r, k, -k / 2.0) }; - vec![Layer::quad(Old, FULL, q, 1.0)] - } else { - let q = if right { persp_h(r, k, -k / 2.0) } else { persp_h(r, -k / 2.0, k) }; - vec![Layer::quad(New, FULL, q, 1.0)] - } - } - "gallery" | "conveyor" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let k = (s * PI).sin(); - let sc = 1.0 - if kind == "gallery" { 0.25 } else { 0.1 } * k; - let gap = if kind == "gallery" { 1.15 } else { 1.05 }; - let old = offset(scale_rect(FULL, sc), d * s * gap, 0.0); - let new = offset(scale_rect(FULL, sc), d * (s - 1.0) * gap, 0.0); - let p = 0.08 * k; - vec![ - Layer::quad(Old, FULL, if d > 0.0 { persp_h(old, 0.0, p) } else { persp_h(old, p, 0.0) }, 1.0), - Layer::quad(New, FULL, if d > 0.0 { persp_h(new, p, 0.0) } else { persp_h(new, 0.0, p) }, 1.0), - ] - } - "cube" | "box" | "rotate" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - let vertical = dy != 0.0 && dx == 0.0; - let neg = if vertical { dy < 0.0 } else { dx < 0.0 }; - let k = (s * PI).sin(); - let (far_old, far_new, shared) = match kind { - "cube" => (0.18 * s, 0.18 * (1.0 - s), 0.05 * k), - "box" => (0.0, 0.0, 0.18 * k), - _ => (0.25 * s, 0.25 * (1.0 - s), 0.0), - }; - // Canonical: moving toward -x; old on the left [0, 1-s], new on the right [1-s, 1]. - let split = 1.0 - s; - let (old_q, new_q) = if kind == "rotate" { - if t < 0.5 { - (Some(canon_q([0.0, 0.0, (1.0 - 2.0 * t).max(0.0), 1.0], far_old, 0.0)), None) - } else { - (None, Some(canon_q([1.0 - (2.0 * t - 1.0), 0.0, 1.0, 1.0], 0.0, far_new))) - } - } else { - (Some(canon_q([0.0, 0.0, split, 1.0], far_old, shared)), Some(canon_q([split, 0.0, 1.0, 1.0], shared, far_new))) - }; - let fix = |q: [[f64; 2]; 4]| -> [[f64; 2]; 4] { - let q = if neg { q } else { mirror_q(q) }; - if vertical { swap_q(q) } else { q } - }; - let mut v = vec![Layer::full(Black, 1.0)]; - if let Some(q) = old_q { - v.push(Layer::quad(Old, FULL, fix(q), 1.0)); - } - if let Some(q) = new_q { - v.push(Layer::quad(New, FULL, fix(q), 1.0)); - } - v - } - "doors" | "window" => { - let horz = o == "horz"; - let (zoom0, persp) = if kind == "doors" { (0.85, 0.1) } else { (0.6, 0.2) }; - let w = 0.5 * (1.0 - s); - let k = persp * s; - let left = [[0.0, 0.0], [w, -k], [w, 1.0 + k], [0.0, 1.0]]; - let right = [[1.0 - w, -k], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 + k]]; - let (l, r) = if horz { (swap_q(left), swap_q(right)) } else { (left, right) }; - let (sl, sr) = if horz { ([0.0, 0.0, 1.0, 0.5], [0.0, 0.5, 1.0, 1.0]) } else { ([0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]) }; - vec![scaled(New, zoom0 + (1.0 - zoom0) * s, 0.5, 0.5, 0.0, 0.0, s.max(0.2)), Layer::quad(Old, sl, l, 1.0), Layer::quad(Old, sr, r, 1.0)] - } - "comb" => { - let vert = o == "vert"; - let n = 8; - let mut v = Vec::new(); - for i in 0..n { - let a = i as f64 / n as f64; - let b = (i + 1) as f64 / n as f64; - let d = if i % 2 == 0 { 1.0 } else { -1.0 }; - let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] }; - let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) }; - let (nx, ny) = if vert { (0.0, -d * (1.0 - s)) } else { (-d * (1.0 - s), 0.0) }; - v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0)); - v.push(Layer::rect(New, band, offset(band, nx, ny), 1.0)); - } - v - } - "zoom" => { - if o == "out" { - vec![scaled(New, 1.5 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 - 0.7 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)] - } else { - vec![scaled(Old, 1.0 + s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, s.max(0.01), 0.5, 0.5, 0.0, 0.0, s)] - } - } - "orbit" => { - let (dx, dy) = dir(o, (-1.0, 0.0)); - let k = (s * PI).sin(); - let old = scaled(Old, 1.0 - 0.4 * k, 0.5, 0.5, dx * s + dy.abs() * 0.15 * k, dy * s + dx.abs() * 0.15 * k, 1.0 - 0.5 * s); - let new = scaled(New, 1.0 - 0.4 * k, 0.5, 0.5, dx * (s - 1.0) - dy.abs() * 0.15 * k, dy * (s - 1.0) - dx.abs() * 0.15 * k, 0.5 + 0.5 * s); - if t < 0.5 { vec![new, old] } else { vec![old, new] } - } - "flythrough" => { - let bounce = o.ends_with("Bounce"); - let ns = |base: f64| if bounce { base + 0.08 * (s * PI).sin() } else { base }; - if o.starts_with("out") { - vec![scaled(Old, 1.0 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, ns(1.5 - 0.5 * s), 0.5, 0.5, 0.0, 0.0, s)] - } else { - vec![scaled(New, ns(0.5 + 0.5 * s), 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 + 1.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)] - } - } - "ferris" => { - let d = if o == "r" { 1.0 } else { -1.0 }; - let pivot = [0.5, 2.5]; - let a = 50.0 * s * d; - let oq = rot_quad(corners(FULL), pivot, a); - let nq = rot_quad(corners(FULL), pivot, a - 50.0 * d); - vec![Layer::quad(Old, FULL, oq, 1.0), Layer::quad(New, FULL, nq, 1.0)] - } - "random" if depth == 0 => { - let h = option.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64)); - let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade"); - let opt = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or(""); - layers(k, opt, t, depth + 1) - } - _ => crossfade(t), + "pan" | "ferris" | "conveyor" | "rotate" | "window" | "orbit" | "flythrough" => true, + "reveal" => !option.starts_with("black"), + _ => false, } } - -fn mirror_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] { - // Mirror x and keep the tl, tr, br, bl order (swap left/right corners). - let m = |p: [f64; 2]| [1.0 - p[0], p[1]]; - [m(q[1]), m(q[0]), m(q[3]), m(q[2])] -} - -fn swap_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] { - // Transpose x/y; corners reordered so tl, tr, br, bl still map to the source's corners. - let s = |p: [f64; 2]| [p[1], p[0]]; - [s(q[0]), s(q[3]), s(q[2]), s(q[1])] -} - -fn canon_q(r: R, left_inset: f64, right_inset: f64) -> [[f64; 2]; 4] { - persp_h(r, left_inset, right_inset) -} - -fn wipe(o: &str, t: f64) -> Vec { - let mut v = vec![Layer::full(Source::Old, 1.0)]; - let diag = o.len() == 2; - if diag { - // Diagonal: columns revealed by a slanted edge. - let (mx, my) = (o.contains('l'), o.contains('u')); - let n = 32; - for i in 0..n { - let x0 = i as f64 / n as f64; - let x1 = (i + 1) as f64 / n as f64; - let h = (2.0 * t - (x0 + x1) / 2.0).clamp(0.0, 1.0); - if h > 0.0 { - v.push(Layer::inplace(Source::New, map_rect([x0, 0.0, x1, h], mx, my, false), 1.0)); - } - } - return v; - } - let (mx, swap) = match o { - "l" => (true, false), - "d" => (false, true), - "u" => (true, true), - _ => (false, false), - }; - let soft = 0.15; - let e = t * (1.0 + soft); - let solid = (e - soft).clamp(0.0, 1.0); - if solid > 0.0 { - v.push(Layer::inplace(Source::New, map_rect([0.0, 0.0, solid, 1.0], mx, false, swap), 1.0)); - } - let n = 6; - for k in 0..n { - let a = e - soft + soft * k as f64 / n as f64; - let b = e - soft + soft * (k + 1) as f64 / n as f64; - let (a, b) = (a.clamp(0.0, 1.0), b.clamp(0.0, 1.0)); - if b > a { - let alpha = 1.0 - (k as f64 + 0.5) / n as f64; - v.push(Layer::inplace(Source::New, map_rect([a, 0.0, b, 1.0], mx, false, swap), alpha)); - } - } - v -} - -/// A centred shape of the source at size `f` (0 = nothing, 1 = covers the slide), as strips. -fn shape_form(src: Source, form: &str, f: f64) -> Vec { - let f = f.clamp(0.0, 1.0); - if f <= 0.0 { - return vec![]; - } - match form { - "plus" => { - let h = f / 2.0; - vec![Layer::inplace(src, [0.0, 0.5 - h, 1.0, 0.5 + h], 1.0), Layer::inplace(src, [0.5 - h, 0.0, 0.5 + h, 1.0], 1.0)] - } - "circle" | "diamond" => { - let n = 40; - let r = if form == "circle" { f * 0.75 } else { f * 1.02 }; - let mut v = Vec::new(); - for i in 0..n { - let y0 = i as f64 / n as f64; - let y1 = (i + 1) as f64 / n as f64; - let dy = ((y0 + y1) / 2.0 - 0.5).abs(); - let half = if form == "circle" { if r > dy { (r * r - dy * dy).sqrt() } else { 0.0 } } else { r - dy }; - if half > 0.0 { - let half = half.min(0.5); - v.push(Layer::inplace(src, [0.5 - half, y0, 0.5 + half, y1], 1.0)); - } - } - v - } - _ => vec![Layer::inplace(src, scale_rect(FULL, f), 1.0)], - } -} - -/// A peeling page: the part of `area` not yet peeled stays, the peeled part shows as a mirrored flap. -fn peel(src: Source, area: R, s: f64, toward_right: bool) -> Vec { - let w = area[2] - area[0]; - let p = s * w; - if toward_right { - let edge = area[0] + p; - let stay = [edge, area[1], area[2], area[3]]; - let flap_src = [area[0], area[1], edge, area[3]]; - let x2 = edge + p; - let flap = Layer { - source: src, - src: flap_src, - quad: [[x2, area[1]], [edge, area[1]], [edge, area[3]], [x2, area[3]]], - uv: corners(flap_src), - alpha: 0.85, - }; - vec![Layer::inplace(src, stay, 1.0), flap] - } else { - let edge = area[2] - p; - let stay = [area[0], area[1], edge, area[3]]; - let flap_src = [edge, area[1], area[2], area[3]]; - let x2 = edge - p; - let flap = Layer { - source: src, - src: flap_src, - quad: [[edge, area[1]], [x2, area[1]], [x2, area[3]], [edge, area[3]]], - uv: corners(flap_src), - alpha: 0.85, - }; - vec![Layer::inplace(src, stay, 1.0), flap] - } -} - -/// Point on the unit square's boundary seen from the centre at angle `a` (radians, clockwise from -/// 12 o'clock). -fn boundary(a: f64) -> [f64; 2] { - let (dx, dy) = (a.sin(), -a.cos()); - let m = dx.abs().max(dy.abs()).max(1e-9); - [0.5 + 0.5 * dx / m, 0.5 + 0.5 * dy / m] -} - -fn clock(o: &str, s: f64) -> Vec { - let mut v = vec![Layer::full(Source::Old, 1.0)]; - let c = [0.5, 0.5]; - let sweep = s * 2.0 * PI; - let (a0, a1) = match o { - "counterClockwise" => (-sweep, 0.0), - "wedge" => (-sweep / 2.0, sweep / 2.0), - _ => (0.0, sweep), - }; - let span = a1 - a0; - if span <= 0.0 { - return v; - } - let n = ((span / (PI / 24.0)).ceil() as usize).clamp(1, 96); - let mut angles: Vec = (0..=n).map(|k| a0 + span * k as f64 / n as f64).collect(); - // Square corners inside the sweep, so the fan reaches them. - for k in -8i32..=8 { - let a = PI / 4.0 + k as f64 * PI / 2.0; - if a > a0 && a < a1 { - angles.push(a); - } - } - angles.sort_by(f64::total_cmp); - for w in angles.windows(2) { - if let [x, y] = w - && y > x - { - v.push(Layer::tri(Source::New, c, boundary(*x), boundary(*y), 1.0)); - } - } - v -} diff --git a/crates/anim/src/transition_fx.rs b/crates/anim/src/transition_fx.rs new file mode 100644 index 0000000..d5fb2e1 --- /dev/null +++ b/crates/anim/src/transition_fx.rs @@ -0,0 +1,1169 @@ +//! The transition effects, as measured on PowerPoint (see [`crate::transition`]). +//! +//! `t` is linear progress (0..1) through the transition's duration; each effect applies its own +//! curve. Directions follow the file format's option: for Push, Wipe, Cover, Uncover, Pan, Vortex, +//! Cube… `dir` is where things move (`l`: toward the left); for Glitter it is the side the effect +//! starts from; Ripple's corner is where the wave heads. + +use std::f64::consts::PI; + +use crate::easing::ease; +use crate::transition::{FULL, Layer, R, Source}; +use crate::transition_geom::*; + +use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White}; + +const RANDOM_KINDS: &[&str] = &[ + "fade", + "push", + "wipe", + "split", + "reveal", + "randomBar", + "shape", + "uncover", + "cover", + "fallOver", + "drape", + "curtains", + "wind", + "prestige", + "fracture", + "crush", + "peelOff", + "pageCurlDouble", + "airplane", + "origami", + "dissolve", + "checker", + "blinds", + "clock", + "ripple", + "honeycomb", + "glitter", + "vortex", + "shred", + "switch", + "flip", + "gallery", + "cube", + "doors", + "box", + "comb", + "zoom", + "pan", + "ferris", + "conveyor", + "rotate", + "window", + "orbit", + "flythrough", +]; + +pub(crate) fn layers(kind: &str, o: &str, t: f64, a: f64, depth: u32) -> Vec { + match kind { + "none" => vec![Layer::full(New, 1.0)], + "cut" => { + if o == "throughBlack" && t < 0.5 { + vec![Layer::full(Black, 1.0)] + } else { + vec![Layer::full(New, 1.0)] + } + } + "fade" if o == "throughBlack" => through_black(t, 0.5, 0.5), + "fade" | "morph" => vec![Layer::full(Old, 1.0), Layer::full(New, ss(t))], + "push" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + let s = q2(t); + vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] + } + "uncover" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + // Diagonal: the old slide accelerates off in the first third. + let s = if o.len() == 2 { ein(win(t, 0.05, 0.35)) } else { pow_inout(t, 3.0) }; + vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)] + } + "cover" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + // Diagonal: the new slide shoots in and settles slowly. + let s = if o.len() == 2 { 1.0 - (1.0 - win(t, 0.07, 0.95)).powi(3) } else { pow_inout(t, 4.0) }; + vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] + } + "wipe" => wipe(o, t), + "split" => split(o, t), + "reveal" => reveal(o, t), + "randomBar" => random_bars(o == "vert", t), + "shape" => shape(o, t, a), + "flash" => flash(t), + "dissolve" => dissolve(t), + "checker" => checker(o == "vert", t, a), + "blinds" => blinds(o == "vert", t, a), + "clock" => clock(o, t, a), + "ripple" => ripple(o, t, a), + "comb" => comb(o == "vert", t), + "zoom" => zoom(o, t, a), + "pan" => pan(o, t), + "fallOver" => fall_over(o == "r", t, a), + "drape" => drape(o == "r", t, a), + "curtains" => curtains(t, a), + "wind" => wind(o == "r", t, a), + "prestige" => prestige(t, a), + "fracture" => fracture(t, a), + "crush" => crush(t, a), + "peelOff" => peel_off(o == "r", t), + "pageCurlDouble" => page_curl(o, t), + "airplane" => airplane(o == "r", t, a), + "origami" => origami(o == "r", t, a), + "honeycomb" => honeycomb(t, a), + "glitter" => glitter(o, t, a), + "vortex" => vortex(o, t, a), + "shred" => shred(o, t, a), + "switch" => switch(o == "r", t, a), + "flip" => flip(o == "r", t, a), + "gallery" => gallery(o == "r", t, a), + "conveyor" => conveyor(o == "r", t, a), + "cube" | "box" | "rotate" | "orbit" => prism(kind, o, t, a), + "doors" | "window" => doors(kind == "window", o == "horz", t, a), + "ferris" => ferris(o == "r", t, a), + "flythrough" => flythrough(o, t), + "random" if depth == 0 => { + let h = o.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64)); + let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade"); + let opt = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or(""); + layers(k, opt, t, a, depth + 1) + } + _ => vec![Layer::full(Old, 1.0), Layer::full(New, ss(t))], + } +} + +/// Old fades to black over [0, `out`], new fades in from black over [`inn`, 1] (smoothstep). +fn through_black(t: f64, out: f64, inn: f64) -> Vec { + let mut v = vec![Layer::full(Black, 1.0)]; + if t < out { + v.push(Layer::full(Old, 1.0 - ss(win(t, 0.0, out)))); + } + if t > inn { + v.push(Layer::full(New, ss(win(t, inn, 1.0)))); + } + v +} + +// ---------- Subtle ---------- + +/// Width of the soft edge of Wipe and Split (slide units along the motion). +const WIPE_SOFT: f64 = 0.8; + +/// Where the middle of the soft edge is at `t`: from fully before the slide to fully past it. +fn wipe_pos(t: f64) -> f64 { + -WIPE_SOFT / 2.0 + (1.0 + WIPE_SOFT) * 1.05 * t +} + +fn wipe(o: &str, t: f64) -> Vec { + let c = wipe_pos(t); + // Position along the motion, 0 where the new slide starts showing. + let p: Box f64> = match o { + "l" => Box::new(|x, _| 1.0 - x), + "u" => Box::new(|_, y| 1.0 - y), + "d" => Box::new(|_, y| y), + "lu" => Box::new(|x, y| (2.0 - x - y) / 2.0), + "ru" => Box::new(|x, y| (1.0 + x - y) / 2.0), + "ld" => Box::new(|x, y| (1.0 - x + y) / 2.0), + "rd" => Box::new(|x, y| (x + y) / 2.0), + _ => Box::new(|x, _| x), + }; + let diag = o.len() == 2; + let (n, m) = if diag { + (32, 18) + } else if matches!(o, "u" | "d") { + (1, 36) + } else { + (48, 1) + }; + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(field(New, n, m, &|x, y| edge(c, p(x, y), WIPE_SOFT))); + v +} + +fn split(o: &str, t: f64) -> Vec { + let horz = o.starts_with("horz"); + let inward = o.ends_with("In"); + let c = wipe_pos(t); + let p = move |x: f64, y: f64| { + let d = if horz { (y - 0.5).abs() * 2.0 } else { (x - 0.5).abs() * 2.0 }; + if inward { 1.0 - d } else { d } + }; + let (n, m) = if horz { (1, 36) } else { (48, 1) }; + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(field(New, n, m, &|x, y| edge(c, p(x, y), WIPE_SOFT))); + v +} + +fn reveal(o: &str, t: f64) -> Vec { + let dx = if o.ends_with("Right") { 1.0 } else { -1.0 }; + if o.starts_with("black") { + // Old slides away fading to black, a pause on black, new slides in from black. + let mut v = vec![Layer::full(Black, 1.0)]; + if t < 0.4 { + let u = win(t, 0.0, 0.38); + v.push(moved(Old, dx * 0.12 * ein(u), 0.0, 1.0 - ss(u))); + } + if t > 0.55 { + let u = win(t, 0.55, 1.0); + v.push(moved(New, -dx * 0.12 * (1.0 - eout(u)), 0.0, ss(u))); + } + return v; + } + // Smoothly: the backgrounds cross-fade while the old content slides off fading and the new + // content follows. + let mut v = vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))]; + if t < 0.5 { + let u = win(t, 0.0, 0.45); + v.push(moved(OldContent, dx * 0.12 * ein(u), 0.0, 1.0 - ss(u))); + } + if t > 0.45 { + let u = win(t, 0.47, 0.95); + v.push(moved(NewContent, -dx * 0.12 * (1.0 - eout(u)), 0.0, ss(u))); + } + v +} + +fn random_bars(vert: bool, t: f64) -> Vec { + // Thin bars, each cross-fading quickly at its own random moment. + let n = 160usize; + let w = 0.2; + let mut v = vec![Layer::full(Old, 1.0)]; + for i in 0..n { + let start = rnd(i as u64 * 7 + 17) * (1.0 - w); + let k = win(t, start, start + w); + if k <= 0.0 { + continue; + } + let (a, b) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let r: R = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] }; + v.push(Layer::inplace(New, r, k)); + } + v +} + +fn shape(o: &str, t: f64, a: f64) -> Vec { + match o { + // Shape In: the old slide shrinks into the centre over the first half. + "in" => { + let s = 1.0 - win(t, 0.0, 0.5); + let mut v = vec![Layer::full(New, 1.0)]; + if s > 0.0 { + v.extend(soft_rect(Old, FULL, scale_rect(FULL, s), 0.012, 1.0)); + } + v + } + // Shape Out: the new slide grows out of the centre over the second half. + "out" => { + let s = win(t, 0.5, 1.0); + let mut v = vec![Layer::full(Old, 1.0)]; + if s > 0.0 { + v.extend(soft_rect(New, FULL, scale_rect(FULL, s), 0.012, 1.0)); + } + v + } + _ => { + let soft = 0.18; + let c = -soft / 2.0 + (1.0 + soft) * t; + let f: Box f64> = match o { + "diamond" => Box::new(move |x, y| edge(c, (x - 0.5).abs() + (y - 0.5).abs(), soft)), + "plus" => Box::new(move |x, y| edge(c, (x - 0.5).abs().min((y - 0.5).abs()) * 2.0, soft)), + _ => { + let rmax = ((0.5 * a).powi(2) + 0.25).sqrt(); + Box::new(move |x, y| edge(c, (((x - 0.5) * a).powi(2) + (y - 0.5).powi(2)).sqrt() / rmax, soft)) + } + }; + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(field(New, 48, 27, &*f)); + v + } + } +} + +fn flash(t: f64) -> Vec { + // A flash bulb: the old slide burns out to white fast, the white holds, then fades off the new + // slide. + let white = if t < 0.1 { + t / 0.1 + } else if t < 0.47 { + 1.0 + } else { + 1.0 - win(t, 0.47, 0.88) + }; + let base = if t < 0.2 { Old } else { New }; + vec![Layer::full(base, 1.0), Layer::full(White, white)] +} + +// ---------- Exciting: patterns ---------- + +fn dissolve(t: f64) -> Vec { + let (n, m) = (48usize, 27usize); + let mut v = vec![Layer::full(Old, 1.0)]; + let cell = |c: usize, r: usize| [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + for r in 0..m { + for c in 0..n { + if t > rnd((r * n + c) as u64 + 991) * 0.97 { + v.push(Layer::inplace(New, cell(c, r), 1.0)); + } + } + } + v +} + +/// One tile flipping in 3-D about its own vertical (or horizontal) axis: old face for the first +/// half of `k`, new face for the second. +fn flip_tile(cam: &Cam, cell: R, k: f64, vertical_axis: bool, out: &mut Vec) { + if k <= 0.0 { + out.push(Layer::inplace(Old, cell, 1.0)); + return; + } + if k >= 1.0 { + out.push(Layer::inplace(New, cell, 1.0)); + return; + } + let ang = 180.0 * k; + let cc = center(cell); + let c3 = cam.unit(cc[0], cc[1]); + let (src, deg) = if k < 0.5 { (Old, ang) } else { (New, ang - 180.0) }; + let pts = cam.rect(cell).map(|p| if vertical_axis { p.yaw(c3, deg) } else { p.pitch(c3, deg) }); + let sh = facing_shade(pts, 0.6); + out.extend(card(cam, src, cell, pts, 1.0, sh, true)); +} + +fn checker(vert: bool, t: f64, a: f64) -> Vec { + let (n, m) = (10usize, 8usize); + let cam = Cam { a, d: 3.0 }; + let mut v = vec![Layer::full(Black, 1.0)]; + for r in 0..m { + for c in 0..n { + let cell = [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + let across = if vert { r as f64 / m as f64 } else { c as f64 / n as f64 }; + let start = across * 0.55 + rnd((r * n + c) as u64 + 5) * 0.12; + let k = q2(win(t, start, start + 0.3)); + flip_tile(&cam, cell, k, !vert, &mut v); + } + } + v +} + +fn blinds(vert: bool, t: f64, a: f64) -> Vec { + let n = 12usize; + let cam = Cam { a, d: 3.0 }; + let mut v = vec![Layer::full(Black, 1.0)]; + for i in 0..n { + let (s, e) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let cell = if vert { [s, 0.0, e, 1.0] } else { [0.0, s, 1.0, e] }; + let start = 0.1 + 0.3 * i as f64 / n as f64; + let k = q2(win(t, start, start + 0.4)); + flip_tile(&cam, cell, k, vert, &mut v); + } + v +} + +fn clock(o: &str, t: f64, a: f64) -> Vec { + // The hand sweeps at a constant speed with a soft leading edge. + let soft = 0.12; + let c = -soft / 2.0 + (1.0 + soft) * t; + let f = move |ang: f64| -> f64 { + let p = match o { + "counterClockwise" => (2.0 * PI - ang) / (2.0 * PI), + "wedge" => { + let d = if ang > PI { 2.0 * PI - ang } else { ang }; + d / PI + } + _ => ang / (2.0 * PI), + }; + edge(c, p, soft) + }; + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(fan(New, a, 240, &f)); + v +} + +fn ripple(o: &str, t: f64, a: f64) -> Vec { + // A soft disc of the new slide spreads from the centre, or from the corner opposite to where + // the wave heads. + let origin = match o { + "lu" => [1.0, 1.0], + "ru" => [0.0, 1.0], + "ld" => [1.0, 0.0], + "rd" => [0.0, 0.0], + _ => [0.5, 0.5], + }; + let far = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]] + .iter() + .map(|p: &[f64; 2]| (((p[0] - origin[0]) * a).powi(2) + (p[1] - origin[1]).powi(2)).sqrt()) + .fold(0.0, f64::max); + let soft = 0.16; + let c = -soft / 2.0 + (1.0 + soft) * t; + let mut v = vec![Layer::full(Old, 1.0)]; + let f = |x: f64, y: f64| { + let d = (((x - origin[0]) * a).powi(2) + (y - origin[1]).powi(2)).sqrt() / far.max(1e-6); + edge(c, d, soft) + }; + v.extend(field(New, 48, 27, &f)); + // Rings of shade travelling inside the wave. + let rings = |x: f64, y: f64| { + let d = (((x - origin[0]) * a).powi(2) + (y - origin[1]).powi(2)).sqrt() / far.max(1e-6); + let inside = edge(c, d, soft); + let w = ((d - c) * 30.0).sin().abs(); + 0.18 * inside * (1.0 - w) * (1.0 - t) + }; + v.extend(field(Black, 48, 27, &rings)); + v +} + +fn comb(vert: bool, t: f64) -> Vec { + // Bands of the old slide slide off to alternate sides, one after another from the last band, + // uncovering the new slide. + let n = if vert { 10usize } else { 7usize }; + let mut v = vec![Layer::full(New, 1.0)]; + for i in 0..n { + let (a, b) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let d = if (n - 1 - i) % 2 == 0 { 1.0 } else { -1.0 }; + let start = 0.06 + 0.05 * (n - 1 - i) as f64 * 7.0 / n as f64; + let s = ein(win(t, start, start + 0.55)); + if s >= 1.0 { + continue; + } + let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] }; + let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) }; + v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0)); + } + v +} + +fn zoom(o: &str, t: f64, a: f64) -> Vec { + match o { + // Zoom and Rotate: the new slide spins in from the centre, a full clockwise turn, fading + // in as it grows. + "rotate" => { + let u = win(t, 0.1, 0.98); + let s = 0.1 + 0.9 * u; + let ang = 360.0 * (1.0 - u); + let r = scale_rect(FULL, s); + let q = rot_quad(crate::transition::corners(r), [0.5, 0.5], ang, a); + vec![Layer::full(Old, 1.0), Layer::quad(New, FULL, q, 0.35 + 0.65 * ss(win(t, 0.0, 0.45)))] + } + // Zoom Out: the new slide comes down from large underneath while the old one shrinks away + // fading. + "out" => { + let s = eout(win(t, 0.0, 0.8)); + vec![ + scaled(New, 1.9 - 0.9 * s, 0.5, 0.5, 0.0, 0.0, 1.0), + scaled(Old, 1.0 - 0.5 * eout(t), 0.5, 0.5, 0.0, 0.0, 1.0 - ss(win(t, 0.0, 0.95))), + ] + } + // Zoom In: the old slide grows toward the viewer fading, the new one grows into place + // from small. + _ => vec![ + Layer::full(Black, 1.0), + scaled(Old, 1.0 + 0.3 * ss(t), 0.5, 0.5, 0.0, 0.0, 1.0 - ss(win(t, 0.0, 0.9))), + scaled(New, 0.5 + 0.5 * ss(win(t, 0.15, 0.95)), 0.5, 0.5, 0.0, 0.0, ss(win(t, 0.0, 0.85))), + ], + } +} + +fn fall_over(right: bool, t: f64, a: f64) -> Vec { + // The old slide tips forward about its bottom edge and falls out of view. + let cam = Cam { a, d: 2.2 }; + let u = ein(win(t, 0.0, 0.88)); + let ang = 92.0 * u; + let pivot = v3(0.0, 0.5, 0.0); + let side = if right { 1.0 } else { -1.0 }; + let pts = cam.rect(FULL).map(|p| p.pitch(pivot, ang).roll(pivot, side * 6.0 * u)); + let mut v = vec![Layer::full(New, 1.0)]; + if u < 1.0 { + v.extend(card(&cam, Old, FULL, pts, 1.0, facing_shade(pts, 0.4), true)); + } + v +} + +fn drape(right: bool, t: f64, a: f64) -> Vec { + // The new slide drops from its top edge like cloth over the old one. + let cam = Cam { a, d: 2.2 }; + let u = q2(win(t, 0.25, 0.62)); + let wob = 4.0 * (1.0 - win(t, 0.6, 1.0)) * (win(t, 0.6, 1.0) * PI * 2.0).sin(); + let ang = 90.0 * (1.0 - u) + wob * u; + let pivot = v3(0.0, -0.5, 0.0); + let side = if right { -1.0 } else { 1.0 }; + let pts = cam.rect(FULL).map(|p| p.pitch(pivot, -ang).roll(pivot, side * 3.0 * (1.0 - u))); + let mut v = vec![Layer::full(Old, 1.0)]; + if u > 0.0 { + v.extend(card(&cam, New, FULL, pts, 1.0, facing_shade(pts, 0.5), true)); + } + v +} + +fn curtains(t: f64, a: f64) -> Vec { + // The old slide parts in the middle into two curtains drawn to the sides, then lifted away. + let open = q2(win(t, 0.0, 0.35)); + let lift = ein(win(t, 0.62, 0.9)); + let mut v = vec![Layer::full(New, 1.0)]; + let w_top = 0.5 - 0.22 * open; + let w_bot = 0.5 - 0.12 * open; + let up = 0.15 * lift; + let sw = 0.75 * lift; + let left = [[-sw, -up], [w_top - sw, -up], [w_bot - 0.6 * sw, 1.0 - up], [-0.6 * sw, 1.0 - up]]; + let right = [[1.0 - w_top + sw, -up], [1.0 + sw, -up], [1.0 + 0.6 * sw, 1.0 - up], [1.0 - w_bot + 0.6 * sw, 1.0 - up]]; + v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], left, 1.0)); + v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], right, 1.0)); + let _ = a; + v +} + +fn wind(right: bool, t: f64, a: f64) -> Vec { + // The old slide blows away like a sheet, lifting and receding toward one side. + let cam = Cam { a, d: 2.2 }; + let u = ein(win(t, 0.12, 0.8)); + let side = if right { 1.0 } else { -1.0 }; + let pivot = v3(0.0, 0.0, 0.0); + let pts = cam.rect(FULL).map(|p| { + let w = p.pitch(v3(0.0, -0.5, 0.0), -25.0 * u).yaw(pivot, -side * 20.0 * u); + v3(w.x + side * 1.6 * u * a / 2.0, w.y - 0.8 * u, w.z + 0.5 * u) + }); + let mut v = vec![Layer::full(New, 1.0)]; + if u < 1.0 { + v.extend(card(&cam, Old, FULL, pts, 1.0, 0.0, true)); + } + v +} + +fn prestige(t: f64, a: f64) -> Vec { + // The old slide lifts at its bottom edge and flies off upward. + let cam = Cam { a, d: 2.2 }; + let lift = q2(win(t, 0.4, 0.62)); + let fly = ein(win(t, 0.5, 0.85)); + let pivot = v3(0.0, -0.5, 0.0); + let pts = cam.rect(FULL).map(|p| { + let w = p.pitch(pivot, -40.0 * lift).roll(pivot, -12.0 * fly); + v3(w.x - 0.4 * fly * a / 2.0, w.y - 1.3 * fly, w.z - 0.2 * fly) + }); + let mut v = vec![Layer::full(New, 1.0)]; + if fly < 1.0 { + v.extend(card(&cam, Old, FULL, pts, 1.0, facing_shade(pts, 0.3), true)); + } + v +} + +fn fracture(t: f64, a: f64) -> Vec { + // The old slide cracks into long shards radiating from its centre; the shards part a little, + // then fall away, the top ones first. + let mut v = vec![Layer::full(New, 1.0)]; + let crack = ss(win(t, 0.06, 0.2)); + if crack <= 0.0 { + v.push(Layer::full(Old, 1.0)); + return v; + } + let n = 36usize; + let rings = [0.0, 0.3, 0.6, 1.0]; + let c = [0.5, 0.5]; + for i in 0..n { + let a0 = 2.0 * PI * (i as f64 + 0.35 * rnd(i as u64)) / n as f64; + let a1 = 2.0 * PI * (i as f64 + 1.0 + 0.35 * rnd((i + 1) as u64 % n as u64)) / n as f64; + for r in 0..3 { + let (r0, r1) = (rings[r], if r == 2 { 1.6 } else { rings[r + 1] }); + let p = |ang: f64, rr: f64| -> [f64; 2] { + let b = boundary(ang, a); + [c[0] + (b[0] - c[0]) * rr, c[1] + (b[1] - c[1]) * rr] + }; + let pts = [p(a0, r0), p(a1, r0), p(a1, r1), p(a0, r1)]; + let id = (i * 3 + r) as u64; + let mid = [(pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4.0, (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4.0]; + let start = 0.3 + 0.28 * mid[1].clamp(0.0, 1.0) + 0.1 * rnd(id + 300); + let k = win(t, start, start + 0.35); + let gap = 0.015 * crack; + let out = [(mid[0] - c[0]) * gap * 4.0, (mid[1] - c[1]) * gap * 4.0 + 1.4 * k * k]; + let ang = (rnd(id + 7) - 0.5) * 50.0 * k; + let q = pts.map(|p| { + let p = [mid[0] + (p[0] - mid[0]) * (1.0 - 0.06 * crack) + out[0], mid[1] + (p[1] - mid[1]) * (1.0 - 0.06 * crack) + out[1]]; + rot(p, [mid[0] + out[0], mid[1] + out[1]], ang, a) + }); + let uv = pts.map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]); + if k < 1.0 { + let l = Layer { source: Old, src: FULL, quad: q, uv, alpha: 1.0, fade: [1.0; 4] }; + v.push(l); + v.push(l.shade(0.1 * crack * rnd(id + 9) + 0.2 * k)); + } + } + } + v +} + +fn crush(t: f64, a: f64) -> Vec { + // The old slide crumples into a ball that drops away. + let mut v = vec![Layer::full(New, 1.0)]; + let k = q2(win(t, 0.08, 0.55)); + let drop = ein(win(t, 0.65, 1.0)); + let size = 1.0 - 0.45 * k; + let cx = 0.5 - 0.1 * k + 0.05 * drop; + let cy = 0.5 + 0.05 * k + 0.8 * drop; + let n = 40usize; + let c = [0.5, 0.5]; + for i in 0..n { + let a0 = 2.0 * PI * i as f64 / n as f64; + let a1 = 2.0 * PI * (i + 1) as f64 / n as f64; + let j = |ang: f64, idx: usize| -> [f64; 2] { + let b = boundary(ang, a); + // Corners pull in toward a round ball, with crumpled jags. + let round = { + let (dx, dy) = ((b[0] - 0.5) * a, b[1] - 0.5); + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let r = 0.45; + [0.5 + dx / len * r / a, 0.5 + dy / len * r] + }; + let jag = 1.0 - 0.18 * k * rnd(idx as u64 % n as u64 + 77); + let p = [b[0] + (round[0] - b[0]) * k, b[1] + (round[1] - b[1]) * k]; + [cx + (p[0] - c[0]) * size * jag, cy + (p[1] - c[1]) * size * jag] + }; + let (p0, p1) = (j(a0, i), j(a1, i + 1)); + let (u0, u1) = (boundary(a0, a), boundary(a1, a)); + let l = Layer::tri_uv(Old, [[cx, cy], p0, p1], [c, u0, u1], [1.0; 3], 1.0); + v.push(l); + v.push(l.shade(0.35 * k * rnd(i as u64 + 13))); + } + v +} + +fn peel_off(right: bool, t: f64) -> Vec { + // The old slide peels from a bottom corner toward the opposite top corner; the peeled flap + // shows the page's back folded over. + let u = ss(win(t, 0.2, 0.95)); + // Fold line perpendicular to the diagonal, sweeping from the corner. + let (cx, cy) = if right { (0.0, 1.0) } else { (1.0, 1.0) }; + let nd: [f64; 2] = if right { [1.0, -1.0] } else { [-1.0, -1.0] }; + let len = (nd[0] * nd[0] + nd[1] * nd[1]).sqrt(); + let n = [nd[0] / len, nd[1] / len]; + let span = 2f64.sqrt(); + let d0 = n[0] * cx + n[1] * cy; + let fold = d0 + span * 1.25 * u; + let mut v = vec![Layer::full(New, 1.0)]; + let full = rect_poly(FULL); + // Kept part: beyond the fold line from the corner. + let keep = clip(&full, [-n[0], -n[1]], -fold); + v.extend(poly_layers(Old, &keep, 1.0, &|p| p)); + // Peeled part, reflected over the fold line. + let peeled = clip(&full, n, fold); + let refl = |p: [f64; 2]| { + let d = n[0] * p[0] + n[1] * p[1] - fold; + [p[0] - 2.0 * d * n[0], p[1] - 2.0 * d * n[1]] + }; + let flap = poly_layers(Old, &peeled, 1.0, &refl); + for l in flap { + v.push(l); + v.push(l.shade(0.25)); + } + v +} + +fn page_curl(o: &str, t: f64) -> Vec { + let single = o.starts_with("single"); + let right = o.ends_with('r') || o.ends_with('R'); + let mut v = vec![Layer::full(New, 1.0)]; + // A roll travelling across the old slide: x of the roll's front edge. + let roll = |area: R, k: f64, toward_left: bool, v: &mut Vec| { + let w = area[2] - area[0]; + let r = 0.06; + if toward_left { + let e = area[2] - (w + r) * k; + let keep = [area[0], area[1], e.max(area[0]), area[3]]; + if keep[2] > keep[0] { + v.push(Layer::inplace(Old, keep, 1.0)); + } + // The roll: a band squeezing the rolled-up part. + let band = [e.max(area[0]), area[1], (e + r).min(area[2]).max(area[0]), area[3]]; + if band[2] > band[0] && k < 1.0 { + let src = [band[0], area[1], area[2], area[3]]; + let l = Layer::rect(Old, src, band, 1.0); + v.push(l); + v.push(l.shade(0.3).faded([0.2, 1.0, 1.0, 0.2])); + } + } else { + let e = area[0] + (w + r) * k; + let keep = [e.min(area[2]), area[1], area[2], area[3]]; + if keep[2] > keep[0] { + v.push(Layer::inplace(Old, keep, 1.0)); + } + let band = [(e - r).max(area[0]).min(area[2]), area[1], e.min(area[2]), area[3]]; + if band[2] > band[0] && k < 1.0 { + let src = [area[0], area[1], band[2], area[3]]; + let l = Layer::rect(Old, src, band, 1.0); + v.push(l); + v.push(l.shade(0.3).faded([1.0, 0.2, 0.2, 1.0])); + } + } + }; + if single { + let k = q2(win(t, 0.25, 0.85)); + roll(FULL, k, !right, &mut v); + } else { + // Double: the half on the far side rolls first, then the near half. + let k1 = q2(win(t, 0.1, 0.6)); + let k2 = q2(win(t, 0.45, 0.85)); + if right { + roll([0.0, 0.0, 0.5, 1.0], k1, false, &mut v); + roll([0.5, 0.0, 1.0, 1.0], k2, false, &mut v); + } else { + roll([0.5, 0.0, 1.0, 1.0], k1, true, &mut v); + roll([0.0, 0.0, 0.5, 1.0], k2, true, &mut v); + } + } + v +} + +fn airplane(right: bool, t: f64, a: f64) -> Vec { + // The old slide folds into a big paper dart (nose to the right for "l") that hangs low, then + // flies off up and away. + let fold = q2(win(t, 0.0, 0.3)); + let fly = ein(win(t, 0.82, 0.95)); + let mut v = vec![Layer::full(New, 1.0)]; + if fly >= 1.0 { + return v; + } + let mx = |p: [f64; 2]| if right { [1.0 - p[0], p[1]] } else { p }; + // The dart in its hanging pose ("l"), then flown off. + let drift = 0.04 * win(t, 0.3, 0.82); + let pose = |p: [f64; 2]| -> [f64; 2] { + let p = [p[0] + drift, p[1] + drift]; + let c = [0.75, 0.65]; + let k = 1.0 - 0.85 * fly; + [c[0] + (p[0] - c[0]) * k - 0.4 * fly, c[1] + (p[1] - c[1]) * k - 1.0 * fly] + }; + let dart = [[0.42, 0.28], [1.02, 0.66], [1.0, 0.72], [0.46, 1.08]]; + let flat = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + let pts: Vec<[f64; 2]> = (0..4) + .map(|i| { + let (f, d) = (flat[i], dart[i]); + mx(pose([f[0] + (d[0] - f[0]) * fold, f[1] + (d[1] - f[1]) * fold])) + }) + .collect(); + let uv: Vec<[f64; 2]> = flat.iter().map(|p| mx(*p)).collect(); + if let (Some(p0), Some(p1), Some(p2), Some(p3)) = (pts.first(), pts.get(1), pts.get(2), pts.get(3)) { + let l1 = Layer::tri_uv(Old, [*p0, *p1, *p2], [uv[0], uv[1], uv[2]], [1.0; 3], 1.0); + let l2 = Layer::tri_uv(Old, [*p0, *p2, *p3], [uv[0], uv[2], uv[3]], [1.0; 3], 1.0); + v.push(l1); + v.push(l1.shade(0.1 * fold)); + v.push(l2); + v.push(l2.shade(0.3 * fold)); + } + let _ = a; + v +} + +fn origami(right: bool, t: f64, a: f64) -> Vec { + // The old slide folds into a bird that flies off. + let fold = q2(win(t, 0.0, 0.2)); + let fly = ein(win(t, 0.2, 0.85)); + let side = if right { 1.0 } else { -1.0 }; + let mut v = vec![Layer::full(New, 1.0)]; + if win(t, 0.2, 0.92) >= 1.0 { + return v; + } + let size = 1.0 - 0.35 * fold; + let cx = 0.47 + side * 0.65 * fly; + let cy = 0.42 + 0.05 * fly; + let flap = (t * 18.0).sin() * 0.08 * fold; + let body = |p: [f64; 2]| [cx + (p[0] - 0.5) * size, cy + (p[1] - 0.5) * size]; + let shape = [ + ([0.0, 0.0], [-0.45 * side, -0.25 - flap]), + ([1.0, 0.0], [0.15 * side, -0.4 + flap]), + ([1.0, 1.0], [0.45 * side, 0.05]), + ([0.0, 1.0], [-0.1 * side, 0.2]), + ]; + let pts: Vec<[f64; 2]> = + shape.iter().map(|(flat, bird)| body([flat[0] + (0.5 + bird[0] - flat[0]) * fold, flat[1] + (0.5 + bird[1] - flat[1]) * fold])).collect(); + let uv: Vec<[f64; 2]> = shape.iter().map(|(flat, _)| *flat).collect(); + if let (Some(p0), Some(p1), Some(p2), Some(p3)) = (pts.first(), pts.get(1), pts.get(2), pts.get(3)) { + let l1 = Layer::tri_uv(Old, [*p0, *p1, *p2], [uv[0], uv[1], uv[2]], [1.0; 3], 1.0); + let l2 = Layer::tri_uv(Old, [*p0, *p2, *p3], [uv[0], uv[2], uv[3]], [1.0; 3], 1.0); + v.push(l1); + v.push(l1.shade(0.15 * fold)); + v.push(l2); + v.push(l2.shade(0.3 * fold)); + } + let _ = a; + v +} + +fn honeycomb(t: f64, a: f64) -> Vec { + // The old slide splits into hexagonal cells along dark seams; the cells fly at the viewer and + // out of sight, leaving black; cells of the new slide pop in one by one and close the seams. + let cols = 11usize; + let w = 1.0 / cols as f64; + let rh = w * a * 0.5; + let rows = (1.0 / (rh * 1.5)).ceil() as usize + 2; + let mut v = vec![Layer::full(Black, 1.0)]; + let seam = ss(win(t, 0.0, 0.12)) * (1.0 - ss(win(t, 0.85, 1.0))); + let hex = |cx: f64, cy: f64, s: f64| -> Vec<[f64; 2]> { + (0..6) + .map(|k| { + let ang = PI / 3.0 * k as f64 + PI / 6.0; + [cx + ang.cos() * s * w * 0.577, cy + ang.sin() * s * rh * 0.577 / 0.5 * 0.5] + }) + .collect() + }; + for r in 0..rows { + for c in 0..=cols { + let cx = (c as f64 + if r % 2 == 1 { 0.5 } else { 0.0 }) * w; + let cy = r as f64 * rh * 0.866; + let id = (r * 64 + c) as u64; + let src_hex = hex(cx, cy, 1.0); + let out_start = 0.1 + 0.2 * rnd(id + 1); + let out_k = ein(win(t, out_start, out_start + 0.12)); + let in_start = 0.38 + 0.42 * rnd(id + 3); + let in_k = eout(win(t, in_start, in_start + 0.1)); + let gap = 1.0 - 0.1 * seam; + let mut cell = |src: Source, s: f64, alpha: f64| { + let dst = hex(cx, cy, s); + for k in 0..6 { + let (p0, p1) = (dst[k], dst[(k + 1) % 6]); + let (u0, u1) = (src_hex[k], src_hex[(k + 1) % 6]); + v.push(Layer::tri_uv(src, [[cx, cy], p0, p1], [[cx, cy], u0, u1], [1.0; 3], alpha)); + } + }; + if t < 0.5 && out_k < 1.0 { + cell(Old, gap * (1.0 + 3.0 * out_k), 1.0 - out_k * out_k); + } + if in_k > 0.0 { + cell(New, gap * (0.3 + 0.7 * in_k), 1.0); + } + } + } + v +} + +fn glitter(o: &str, t: f64, a: f64) -> Vec { + // Tiles flip from the old slide to the new in a sparkling sweep starting on the option's side. + let hexes = o.starts_with("hex"); + let d = o.trim_start_matches("hex").to_ascii_lowercase(); + let (n, m) = if hexes { (28usize, 16usize) } else { (40usize, 22usize) }; + let cam = Cam { a, d: 3.0 }; + let mut v = vec![Layer::full(Old, 1.0)]; + for r in 0..m { + for c in 0..n { + let cell = [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + let cc = center(cell); + // Distance from the side the sweep starts on ("r": from the right). + let grad = match d.as_str() { + "r" => 1.0 - cc[0], + "u" => cc[1], + "d" => 1.0 - cc[1], + _ => cc[0], + }; + let start = 0.75 * grad * 0.85 + rnd((r * n + c) as u64 + 77) * 0.12; + let k = win(t, start, start + 0.25); + if k <= 0.0 { + continue; + } + v.push(Layer::inplace(Black, cell, 1.0)); + flip_tile(&cam, cell, k, true, &mut v); + } + } + v +} + +fn vortex(o: &str, t: f64, a: f64) -> Vec { + // The old slide breaks into fine particles that whirl off toward the option's side, the + // trailing side first; the new slide's particles whirl in and settle, the leading side first. + let (dx, dy) = dir(o, (-1.0, 0.0)); + let (n, m) = (48usize, 27usize); + let mut v = vec![Layer::full(Black, 1.0)]; + for r in 0..m { + for c in 0..n { + let id = (r * n + c) as u64; + let cell = [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + let cc = center(cell); + // 1 on the side things move toward, 0 on the other. + let lead = (0.5 + (cc[0] - 0.5) * dx + (cc[1] - 0.5) * dy).clamp(0.0, 1.0); + let go = 0.4 * lead + 0.08 * rnd(id + 31); + let ko = win(t, go, go + 0.35); + let end = 0.62 + 0.3 * (1.0 - lead) + 0.06 * rnd(id + 41); + let kn = 1.0 - win(t, end - 0.35, end); + let mut piece = |src: Source, k: f64| { + let swirl = 2.0 * PI * (rnd(id + 5) - 0.5) * k; + let off = [dx * k * 0.8 + swirl.sin() * 0.15 * k, dy * k * 0.8 - swirl.cos().abs() * 0.12 * k]; + let s = 0.8 - 0.5 * k; + let r2 = offset(scale_rect(cell, s), off[0], off[1]); + let q = rot_quad(crate::transition::corners(r2), center(r2), rnd(id) * 360.0 * k, a); + v.push(Layer::quad(src, cell, q, 1.0 - k * k)); + }; + if kn < 1.0 { + piece(New, kn); + } + if ko > 0.0 && ko < 1.0 && kn >= 1.0 { + piece(Old, ko); + } else if ko <= 0.0 { + v.push(Layer::inplace(Old, cell, 1.0)); + } + } + } + v +} + +fn shred(o: &str, t: f64, a: f64) -> Vec { + let particles = o.starts_with("particles") || o.starts_with("rect"); + let out = o.ends_with("Out"); + let cam = Cam { a, d: 2.5 }; + let mut v = vec![Layer::full(Black, 1.0)]; + // First half: the old slide comes apart and recedes; second half: the new one assembles. + let (src, k) = if t < 0.55 { (Old, q2(win(t, 0.05, 0.55))) } else { (New, 1.0 - q2(win(t, 0.55, 0.95))) }; + if particles { + let (n, m) = (64usize, 36usize); + for r in 0..m { + for c in 0..n { + let id = (r * n + c) as u64; + let cell = [c as f64 / n as f64, r as f64 / m as f64, (c + 1) as f64 / n as f64, (r + 1) as f64 / m as f64]; + let cc = center(cell); + let z = if out { -0.6 } else { 1.5 } * k * (0.5 + rnd(id)); + let jitter = [(rnd(id + 1) - 0.5) * 0.4 * k, (rnd(id + 2) - 0.5) * 0.4 * k]; + let p3 = cam.unit(cc[0] + jitter[0], cc[1] + jitter[1]); + let pc = cam.p(v3(p3.x, p3.y, z)); + let s = 0.8 * (1.0 - 0.3 * k) * cam.d / (cam.d + z).max(0.1); + let r2 = offset(scale_rect(cell, s), pc[0] - cc[0], pc[1] - cc[1]); + let l = Layer::rect(src, cell, r2, 1.0 - 0.3 * k * rnd(id + 5)); + v.push(l); + v.push(l.shade(0.4 * k)); + } + } + } else { + let n = 30usize; + for i in 0..n { + let id = i as u64; + let (x0, x1) = (i as f64 / n as f64, (i + 1) as f64 / n as f64); + let cell = [x0, 0.0, x1, 1.0]; + let z = if out { -0.3 } else { 1.0 } * k * k * (0.6 + rnd(id + 11)); + let tilt = (rnd(id + 12) - 0.5) * 30.0 * k; + let drop = (rnd(id + 13) - 0.3) * 0.25 * k; + let inset = 0.004 * k; + let w3 = cam.rect([x0 + inset, 0.0, x1 - inset, 1.0]); + let c3 = cam.unit((x0 + x1) / 2.0, 0.5); + let pts = w3.map(|p| { + let q = p.roll(c3, tilt * 0.2).yaw(c3, tilt); + v3(q.x, q.y + drop, q.z + z) + }); + v.extend(card(&cam, src, cell, pts, 1.0, 0.3 * k, true)); + } + } + v +} + +fn switch(right: bool, t: f64, a: f64) -> Vec { + // Switching cards: the old slide is pulled out to one side ("l": left) and slipped behind + // while the new one, pulled out to the other side from behind it, comes to the front. Both + // turn a little away at their outer edges. + let cam = Cam { a, d: 2.5 }; + let side = if right { -1.0 } else { 1.0 }; + let u = (t + ss(t)) / 2.0; + let k = (u * PI).sin(); + let shift = 0.5 * k * a; + let old = cam.rect(FULL).map(|p| { + let q = p.yaw(v3(0.0, 0.0, 0.0), side * 14.0 * k); + v3(q.x - side * shift, q.y, q.z + 0.12 * k) + }); + let new = cam.rect(FULL).map(|p| { + let q = p.yaw(v3(0.0, 0.0, 0.0), -side * 14.0 * k); + v3(q.x + side * shift, q.y, q.z + 0.12 * k) + }); + let mut v = vec![Layer::full(Black, 1.0)]; + let o = card(&cam, Old, FULL, old, 1.0, facing_shade(old, 0.3), true); + let n = card(&cam, New, FULL, new, 1.0, facing_shade(new, 0.3), true); + if u < 0.5 { + v.extend(n); + v.extend(o); + } else { + v.extend(o); + v.extend(n); + } + v +} + +fn flip(right: bool, t: f64, a: f64) -> Vec { + // The slide turns over like a card: the old face, then the new one on its back. + let cam = Cam { a, d: 2.5 }; + let s = q2(t); + let side = if right { 1.0 } else { -1.0 }; + let deg = 180.0 * s * side; + let back = 0.6 * (s * PI).sin(); + let c = v3(0.0, 0.0, 0.0); + let pts = cam.rect(FULL).map(|p| { + let q = p.yaw(c, deg); + v3(q.x, q.y, q.z + back) + }); + let mut v = vec![Layer::full(Black, 1.0)]; + if s < 0.5 { + v.extend(card(&cam, Old, FULL, pts, 1.0, facing_shade(pts, 0.5), true)); + } else { + // The back face: the new slide, mirrored so it reads right way round. + let m = [pts[1], pts[0], pts[3], pts[2]]; + v.extend(card(&cam, New, FULL, m, 1.0, facing_shade(m, 0.5), true)); + } + v +} + +fn gallery(right: bool, t: f64, a: f64) -> Vec { + // The slides hang on a wall: it turns away and recedes, the slides slide along it, then it + // turns back. A faint reflection shows below. + let cam = Cam { a, d: 2.5 }; + let side = if right { 1.0 } else { -1.0 }; + let k = ss(win(t, 0.0, 0.35)) * (1.0 - ss(win(t, 0.7, 1.0))); + let s = q2(win(t, 0.3, 0.8)); + let gap = 0.15; + let back = 0.7 * k; + let yaw = -side * 22.0 * k; + let mut v = vec![Layer::full(Black, 1.0)]; + for (src, shift) in [(Old, side * s), (New, side * (s - 1.0))] { + let x0 = shift * (a + gap); + let pts = cam.rect(FULL).map(|p| v3(p.x + x0, p.y, p.z + back)).map(|p| p.yaw(v3(0.0, 0.0, back), yaw)); + // Reflection: the slide flipped below its bottom edge, faint. + let refl = [pts[3], pts[2], v3(pts[2].x, pts[2].y + 0.25, pts[2].z), v3(pts[3].x, pts[3].y + 0.25, pts[3].z)]; + let rq = cam.quad(refl); + v.push(Layer { + source: src, + src: [0.0, 0.75, 1.0, 1.0], + quad: rq, + uv: [[0.0, 1.0], [1.0, 1.0], [1.0, 0.75], [0.0, 0.75]], + alpha: 0.3 * k, + fade: [1.0, 1.0, 0.0, 0.0], + }); + v.extend(card(&cam, src, FULL, pts, 1.0, 0.0, true)); + } + v +} + +fn conveyor(right: bool, t: f64, a: f64) -> Vec { + // The contents ride a belt: it turns away, the contents slide along, it turns back; the + // backgrounds cross-fade underneath. + let cam = Cam { a, d: 2.5 }; + let side = if right { 1.0 } else { -1.0 }; + let k = ss(win(t, 0.0, 0.35)) * (1.0 - ss(win(t, 0.65, 1.0))); + let s = q2(win(t, 0.33, 0.75)); + let back = 0.45 * k; + let yaw = -side * 18.0 * k; + let mut v = vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))]; + for (src, shift) in [(OldContent, side * s), (NewContent, side * (s - 1.0))] { + let x0 = shift * a * 1.05; + let pts = cam.rect(FULL).map(|p| v3(p.x + x0, p.y, p.z + back)).map(|p| p.yaw(v3(0.0, 0.0, back), yaw)); + v.extend(card(&cam, src, FULL, pts, 1.0, 0.0, true)); + } + v +} + +fn prism(kind: &str, o: &str, t: f64, a: f64) -> Vec { + // Cube: the slides are two faces of a cube turning; Box: two inside walls of a box; Rotate and + // Orbit: the same with the contents only, over cross-fading backgrounds. + let (dx, dy) = dir(o, (-1.0, 0.0)); + let vertical = dy != 0.0 && dx == 0.0; + let content = matches!(kind, "rotate" | "orbit"); + let inverted = matches!(kind, "box" | "orbit"); + let cam = Cam { a, d: 2.5 }; + let s = q2(t); + let half = if vertical { 0.5 } else { a / 2.0 }; + // Faces on a cube around (0, 0, half) (outside), or the inside of a box around (0, 0, -half). + let c = v3(0.0, 0.0, if inverted { -half } else { half }); + // Positive turns the right (or top) side away. + let sign = if vertical { -dy } else { dx }; + let deg = 90.0 * s * sign * if inverted { -1.0 } else { 1.0 }; + let turn = |p: V3, base: f64| -> V3 { if vertical { p.pitch(c, base + deg) } else { p.yaw(c, base + deg) } }; + let side_base = -90.0 * sign * if inverted { -1.0 } else { 1.0 }; + let (os, ns) = if content { (OldContent, NewContent) } else { (Old, New) }; + let old = cam.rect(FULL).map(|p| turn(p, 0.0)); + let new = cam.rect(FULL).map(|p| turn(p, side_base)); + // Keep the nearest edge on the screen plane, as PowerPoint's camera does. + let near = old.iter().chain(new.iter()).map(|p| p.z).fold(f64::INFINITY, f64::min); + let lift = if inverted { 0.0 } else { -near.min(0.0) - near.max(0.0) }; + // The box is seen from further back mid-turn. + let back = if inverted { 0.5 } else { 0.15 } * (s * PI).sin(); + let push = |q: [V3; 4]| q.map(|p| v3(p.x, p.y, p.z + lift + back)); + let (old, new) = (push(old), push(new)); + let mut v = if content { vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))] } else { vec![Layer::full(Black, 1.0)] }; + v.extend(card(&cam, os, FULL, old, 1.0, if content { 0.0 } else { facing_shade(old, 0.6) }, false)); + v.extend(card(&cam, ns, FULL, new, 1.0, if content { 0.0 } else { facing_shade(new, 0.6) }, false)); + v +} + +fn doors(window: bool, horz: bool, t: f64, a: f64) -> Vec { + // The old slide parts in two halves that swing open toward the viewer and darken; the new one + // comes forward from the middle, fading in over them. Window does it with the contents only, + // over cross-fading backgrounds. + let cam = Cam { a, d: 2.2 }; + let open = ein(win(t, 0.05, 0.68)); + let come = q2(win(t, 0.0, 0.72)); + let (os, ns) = if window { (OldContent, NewContent) } else { (Old, New) }; + let mut v = if window { vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))] } else { vec![Layer::full(Black, 1.0)] }; + let halves: [(R, f64); 2] = + if horz { [([0.0, 0.0, 1.0, 0.5], -1.0), ([0.0, 0.5, 1.0, 1.0], 1.0)] } else { [([0.0, 0.0, 0.5, 1.0], -1.0), ([0.5, 0.0, 1.0, 1.0], 1.0)] }; + if open < 1.0 { + for (r, sgn) in halves { + let hinge = if horz { cam.unit(0.5, if sgn < 0.0 { 0.0 } else { 1.0 }) } else { cam.unit(if sgn < 0.0 { 0.0 } else { 1.0 }, 0.5) }; + let ang = 30.0 * open; + let slide = 0.55 * open; + let pts = cam.rect(r).map(|p| { + let q = if horz { p.pitch(hinge, sgn * ang) } else { p.yaw(hinge, -sgn * ang) }; + if horz { v3(q.x, q.y + sgn * slide, q.z) } else { v3(q.x + sgn * slide * a, q.y, q.z) } + }); + let dark = if window { 0.0 } else { 0.7 * open }; + let alpha = if window { 1.0 - ss(win(t, 0.2, 0.6)) } else { 1.0 }; + v.extend(card(&cam, os, r, pts, alpha, dark, true)); + } + } + let sc = 0.5 + 0.5 * come; + let fade_in = if window { ss(t) } else { ss(win(t, 0.05, 0.62)) }; + v.push(scaled(ns, sc, 0.5, 0.5, 0.0, 0.0, fade_in)); + v +} + +fn ferris(right: bool, t: f64, a: f64) -> Vec { + // The contents ride a big wheel turning about a hub far off the lower corner: the old ones + // drop away down and sideways, the new ones come down round from the top, over cross-fading + // backgrounds. + let side = if right { -1.0 } else { 1.0 }; + let mut v = vec![Layer::full(OldBack, 1.0), Layer::full(NewBack, ss(t))]; + let out = ein(win(t, 0.0, 0.5)); + if out < 1.0 { + let (dx, dy) = (-side * 0.45 * out.powf(1.5), 0.9 * out); + let q = rot_quad(crate::transition::corners(offset(FULL, dx, dy)), [0.5 + dx, 0.5 + dy], side * -15.0 * out, a); + v.push(Layer::quad(OldContent, FULL, q, 1.0)); + } + let inn = q2(win(t, 0.5, 1.0)); + if inn > 0.0 { + let k = 1.0 - inn; + let (dx, dy) = (-side * 0.5 * k.powf(1.3), -0.75 * k); + let r = offset(scale_rect(FULL, 1.0 - 0.25 * k), dx, dy); + let q = rot_quad(crate::transition::corners(r), center(r), side * -18.0 * k, a); + v.push(Layer::quad(NewContent, FULL, q, 1.0)); + } + v +} + +fn pan(o: &str, t: f64) -> Vec { + // The contents slide along together while the backgrounds cross-fade. + let (dx, dy) = dir(o, (-1.0, 0.0)); + // Quick start, long glide. + let s = ease(t, 0.2, 0.8, 0.0); + vec![ + Layer::full(OldBack, 1.0), + Layer::full(NewBack, s), + moved(OldContent, dx * s, dy * s, 1.0), + moved(NewContent, dx * (s - 1.0), dy * (s - 1.0), 1.0), + ] +} + +fn flythrough(o: &str, t: f64) -> Vec { + // The contents fly past the viewer (in) or recede (out) while the backgrounds cross-fade. + let bounce = o.ends_with("Bounce"); + let out = o.starts_with("out"); + let s = q2(t); + let b = if bounce { 0.08 * (win(t, 0.6, 1.0) * PI).sin() } else { 0.0 }; + let old_a = 1.0 - ss(win(t, 0.0, 0.6)); + let new_a = ss(win(t, 0.3, 0.95)); + let (os, ns) = if out { (1.0 - 0.5 * s, 1.8 - 0.8 * s - b) } else { (1.0 + 1.5 * s, 0.4 + 0.6 * s + b) }; + vec![ + Layer::full(OldBack, 1.0), + Layer::full(NewBack, ss(t)), + scaled(NewContent, ns, 0.5, 0.5, 0.0, 0.0, new_a), + scaled(OldContent, os, 0.5, 0.5, 0.0, 0.0, old_a), + ] +} diff --git a/crates/anim/src/transition_geom.rs b/crates/anim/src/transition_geom.rs new file mode 100644 index 0000000..8918ec4 --- /dev/null +++ b/crates/anim/src/transition_geom.rs @@ -0,0 +1,382 @@ +//! Geometry for transition effects: easing, 3-D cards, opacity fields, polygons. + +use std::f64::consts::PI; + +use crate::easing::{ease, smooth}; +use crate::transition::{FULL, Layer, P, Q, R, Source, corners}; + +// ---------- easing ---------- + +/// Smoothstep ease in-out (PowerPoint's fades). +pub(crate) fn ss(x: f64) -> f64 { + smooth(x) +} + +/// Quadratic ease in-out: velocity ramps up for the first half and down for the second +/// (PowerPoint's moves: Push, Cover, Uncover, Pan…). +pub(crate) fn q2(x: f64) -> f64 { + ease(x, 0.5, 0.5, 0.0) +} + +/// Power ease in-out of degree `n` (2: quadratic, 3: cubic, 4: quartic). +pub(crate) fn pow_inout(x: f64, n: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + if x < 0.5 { 0.5 * (2.0 * x).powf(n) } else { 1.0 - 0.5 * (2.0 - 2.0 * x).powf(n) } +} + +/// Quadratic ease-in. +pub(crate) fn ein(x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + x * x +} + +/// Quadratic ease-out. +pub(crate) fn eout(x: f64) -> f64 { + let x = 1.0 - x.clamp(0.0, 1.0); + 1.0 - x * x +} + +/// Progress through the window [a, b] of t, clamped to 0..1. +pub(crate) fn win(t: f64, a: f64, b: f64) -> f64 { + if b <= a { + return if t >= b { 1.0 } else { 0.0 }; + } + ((t - a) / (b - a)).clamp(0.0, 1.0) +} + +// ---------- randomness ---------- + +pub(crate) fn hash64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + x ^ (x >> 31) +} + +/// Deterministic pseudo-random value in [0, 1). +pub(crate) fn rnd(i: u64) -> f64 { + (hash64(i) >> 11) as f64 / (1u64 << 53) as f64 +} + +// ---------- 2-D ---------- + +pub(crate) fn offset(r: R, dx: f64, dy: f64) -> R { + [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy] +} + +pub(crate) fn center(r: R) -> P { + [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0] +} + +pub(crate) fn scale_rect(r: R, s: f64) -> R { + scale_rect_about(r, s, center(r)) +} + +pub(crate) fn scale_rect_about(r: R, s: f64, c: P) -> R { + [c[0] + (r[0] - c[0]) * s, c[1] + (r[1] - c[1]) * s, c[0] + (r[2] - c[0]) * s, c[1] + (r[3] - c[1]) * s] +} + +/// The whole source moved by (dx, dy). +pub(crate) fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer { + Layer::rect(source, FULL, offset(FULL, dx, dy), alpha) +} + +/// The whole source scaled by `s` about (cx, cy) and moved. +pub(crate) fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer { + Layer::rect(source, FULL, offset(scale_rect_about(FULL, s, [cx, cy]), dx, dy), alpha) +} + +/// Rotate a point about a pivot by `deg` (clockwise on screen), in a slide `a` wide per unit of +/// height, so angles and shapes stay true. +pub(crate) fn rot(p: P, c: P, deg: f64, a: f64) -> P { + let (s, co) = deg.to_radians().sin_cos(); + let (x, y) = ((p[0] - c[0]) * a, p[1] - c[1]); + [c[0] + (x * co - y * s) / a, c[1] + x * s + y * co] +} + +pub(crate) fn rot_quad(q: Q, c: P, deg: f64, a: f64) -> Q { + [rot(q[0], c, deg, a), rot(q[1], c, deg, a), rot(q[2], c, deg, a), rot(q[3], c, deg, a)] +} + +/// Motion direction of an option: l, r, u, d and diagonals. +pub(crate) fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) { + let mut d = (0.0, 0.0); + if opt.contains('l') { + d.0 = -1.0; + } + if opt.contains('r') { + d.0 = 1.0; + } + if opt.contains('u') { + d.1 = -1.0; + } + if opt.contains('d') { + d.1 = 1.0; + } + if d == (0.0, 0.0) { default } else { d } +} + +/// A rectangle with a soft border `f` wide (slide units) fading out to nothing: the rectangle at +/// full opacity, four edge ramps and four corner ramps. `src` maps onto `dst`; the border samples +/// the texture's edge. +pub(crate) fn soft_rect(source: Source, src: R, dst: R, f: f64, alpha: f64) -> Vec { + let mut v = vec![Layer::rect(source, src, dst, alpha)]; + if f <= 1e-6 { + return v; + } + let (l, t, r, b) = (dst[0], dst[1], dst[2], dst[3]); + let (fx, fy) = (f, f * 16.0 / 9.0); + let uv_at = |x: f64, y: f64| -> P { + let u = if r > l { src[0] + (x.clamp(l, r) - l) / (r - l) * (src[2] - src[0]) } else { src[0] }; + let w = if b > t { src[1] + (y.clamp(t, b) - t) / (b - t) * (src[3] - src[1]) } else { src[1] }; + [u, w] + }; + let mut q = |pts: Q, fade: [f64; 4]| { + let uv = pts.map(|p| uv_at(p[0], p[1])); + let xs = uv.map(|p| p[0]); + let ys = uv.map(|p| p[1]); + let mn = |a: [f64; 4]| a.iter().copied().fold(f64::INFINITY, f64::min); + let mx = |a: [f64; 4]| a.iter().copied().fold(f64::NEG_INFINITY, f64::max); + v.push(Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: pts, uv, alpha, fade }); + }; + q([[l, t - fy], [r, t - fy], [r, t], [l, t]], [0.0, 0.0, 1.0, 1.0]); + q([[l, b], [r, b], [r, b + fy], [l, b + fy]], [1.0, 1.0, 0.0, 0.0]); + q([[l - fx, t], [l, t], [l, b], [l - fx, b]], [0.0, 1.0, 1.0, 0.0]); + q([[r, t], [r + fx, t], [r + fx, b], [r, b]], [1.0, 0.0, 0.0, 1.0]); + q([[l - fx, t - fy], [l, t - fy], [l, t], [l - fx, t]], [0.0, 0.0, 1.0, 0.0]); + q([[r, t - fy], [r + fx, t - fy], [r + fx, t], [r, t]], [0.0, 0.0, 0.0, 1.0]); + q([[r, b], [r + fx, b], [r + fx, b + fy], [r, b + fy]], [1.0, 0.0, 0.0, 0.0]); + q([[l - fx, b], [l, b], [l, b + fy], [l - fx, b + fy]], [0.0, 1.0, 0.0, 0.0]); + v +} + +// ---------- opacity fields ---------- + +/// The source shown in place through an opacity field `f(x, y)` (0..1) sampled on an `n` × `m` +/// grid: each cell is a quad with its corners' opacities. Cells with nothing to show are left out +/// and runs of fully opaque cells merge, so most frames stay small. +pub(crate) fn field(source: Source, n: usize, m: usize, f: &dyn Fn(f64, f64) -> f64) -> Vec { + let (n, m) = (n.clamp(1, 256), m.clamp(1, 256)); + let mut vals = vec![0.0; (n + 1) * (m + 1)]; + for j in 0..=m { + for i in 0..=n { + let v = f(i as f64 / n as f64, j as f64 / m as f64); + if let Some(x) = vals.get_mut(j * (n + 1) + i) { + *x = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + } + } + } + let at = |i: usize, j: usize| vals.get(j * (n + 1) + i).copied().unwrap_or(0.0); + let mut out = Vec::new(); + for j in 0..m { + let (y0, y1) = (j as f64 / m as f64, (j + 1) as f64 / m as f64); + let mut run: Option = None; + for i in 0..=n { + let full = i < n && at(i, j) >= 1.0 && at(i + 1, j) >= 1.0 && at(i, j + 1) >= 1.0 && at(i + 1, j + 1) >= 1.0; + if full { + run.get_or_insert(i); + continue; + } + if let Some(s) = run.take() { + out.push(Layer::inplace(source, [s as f64 / n as f64, y0, i as f64 / n as f64, y1], 1.0)); + } + if i == n { + break; + } + let c = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; + if c.iter().all(|v| *v <= 0.0) { + continue; + } + out.push(Layer::inplace(source, [i as f64 / n as f64, y0, (i + 1) as f64 / n as f64, y1], 1.0).faded(c)); + } + } + out +} + +/// Opacity of a soft edge at position `p` when the edge's middle is at `c` and it ramps over `w`. +pub(crate) fn edge(c: f64, p: f64, w: f64) -> f64 { + if w <= 1e-9 { + return if p <= c { 1.0 } else { 0.0 }; + } + ((c - p) / w + 0.5).clamp(0.0, 1.0) +} + +/// Point on the unit square's boundary seen from its centre at angle `ang` (radians, clockwise +/// from 12 o'clock, measured on a slide `a` wide per unit of height). +pub(crate) fn boundary(ang: f64, a: f64) -> P { + let (dx, dy) = (ang.sin() / a, -ang.cos()); + let m = (dx.abs() / 0.5).max(dy.abs() / 0.5).max(1e-9); + [0.5 + dx / m, 0.5 + dy / m] +} + +/// The source shown in place through an angular opacity field `f(angle)` (radians clockwise from +/// 12 o'clock, 0..2π): a fan of thin wedges from the centre. +pub(crate) fn fan(source: Source, a: f64, steps: usize, f: &dyn Fn(f64) -> f64) -> Vec { + let n = steps.clamp(8, 720); + let mut angles: Vec = (0..=n).map(|k| 2.0 * PI * k as f64 / n as f64).collect(); + // The square's corners, so wedges reach them. + for c in [[1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]] { + let ang = ((c[0] - 0.5) * a).atan2(-(c[1] - 0.5)); + angles.push(ang.rem_euclid(2.0 * PI)); + } + angles.sort_by(f64::total_cmp); + angles.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + let mut out = Vec::new(); + let c = [0.5, 0.5]; + for w in angles.windows(2) { + let [x, y] = w else { continue }; + let (fx, fy) = (f(*x).clamp(0.0, 1.0), f(*y).clamp(0.0, 1.0)); + if fx <= 0.0 && fy <= 0.0 { + continue; + } + let (bx, by) = (boundary(*x, a), boundary(*y, a)); + out.push(Layer::tri_uv(source, [c, bx, by], [c, bx, by], [(fx + fy) / 2.0, fx, fy], 1.0)); + } + out +} + +// ---------- polygons ---------- + +/// Keep the part of a convex polygon where `n · p <= d`. +pub(crate) fn clip(poly: &[P], n: P, d: f64) -> Vec

{ + let mut out = Vec::with_capacity(poly.len() + 2); + let side = |p: P| n[0] * p[0] + n[1] * p[1] - d; + for (i, p) in poly.iter().enumerate() { + let q = poly.get((i + 1) % poly.len()).copied().unwrap_or(*p); + let (sp, sq) = (side(*p), side(q)); + if sp <= 0.0 { + out.push(*p); + } + if (sp < 0.0 && sq > 0.0) || (sp > 0.0 && sq < 0.0) { + let k = sp / (sp - sq); + out.push([p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k]); + } + } + out +} + +/// A convex polygon as triangles: vertices drawn at `map(p)` with texture coordinate `p`. +pub(crate) fn poly_layers(source: Source, poly: &[P], alpha: f64, map: &dyn Fn(P) -> P) -> Vec { + let Some(first) = poly.first().copied() else { return vec![] }; + poly.windows(2) + .skip(1) + .filter_map(|w| match w { + [b, c] => Some(Layer::tri_uv(source, [map(first), map(*b), map(*c)], [first, *b, *c], [1.0; 3], alpha)), + _ => None, + }) + .collect() +} + +pub(crate) fn rect_poly(r: R) -> Vec

{ + corners(r).to_vec() +} + +// ---------- 3-D ---------- + +/// A point in the 3-D scene: x right and y down in slide heights (the slide spans x ∈ [−a/2, a/2], +/// y ∈ [−½, ½] at z = 0), z away from the viewer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct V3 { + pub x: f64, + pub y: f64, + pub z: f64, +} + +pub(crate) fn v3(x: f64, y: f64, z: f64) -> V3 { + V3 { x, y, z } +} + +impl V3 { + pub fn add(self, o: V3) -> V3 { + v3(self.x + o.x, self.y + o.y, self.z + o.z) + } + pub fn sub(self, o: V3) -> V3 { + v3(self.x - o.x, self.y - o.y, self.z - o.z) + } + /// Rotate about the vertical axis through `c` (degrees; positive turns the right side away). + pub fn yaw(self, c: V3, deg: f64) -> V3 { + let (s, co) = deg.to_radians().sin_cos(); + let p = self.sub(c); + v3(p.x * co - p.z * s, p.y, p.x * s + p.z * co).add(c) + } + /// Rotate about the horizontal axis through `c` (degrees; positive turns the top away). + pub fn pitch(self, c: V3, deg: f64) -> V3 { + let (s, co) = deg.to_radians().sin_cos(); + let p = self.sub(c); + v3(p.x, p.y * co + p.z * s, -p.y * s + p.z * co).add(c) + } + /// Rotate in the screen plane about `c` (degrees, clockwise). + pub fn roll(self, c: V3, deg: f64) -> V3 { + let (s, co) = deg.to_radians().sin_cos(); + let p = self.sub(c); + v3(p.x * co - p.y * s, p.x * s + p.y * co, p.z).add(c) + } +} + +/// A perspective camera looking at the slide plane from `d` slide heights away. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Cam { + pub a: f64, + pub d: f64, +} + +impl Cam { + pub fn p(&self, v: V3) -> P { + let k = self.d / (self.d + v.z).max(self.d * 0.02); + [v.x * k / self.a + 0.5, v.y * k + 0.5] + } + /// The 3-D corners (tl, tr, br, bl) of the unit-space rectangle `r` lying on the slide plane. + pub fn rect(&self, r: R) -> [V3; 4] { + corners(r).map(|p| v3((p[0] - 0.5) * self.a, p[1] - 0.5, 0.0)) + } + /// The unit-space point of a 3-D point on the slide plane. + pub fn unit(&self, x: f64, y: f64) -> V3 { + v3((x - 0.5) * self.a, y - 0.5, 0.0) + } + pub fn quad(&self, c: [V3; 4]) -> Q { + c.map(|v| self.p(v)) + } +} + +/// Signed area of a quad (positive: front side facing the viewer). +pub(crate) fn area(q: &Q) -> f64 { + let mut s = 0.0; + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + s += a[0] * b[1] - b[0] * a[1]; + } + s / 2.0 +} + +/// A card: `src` of the source mapped onto 3-D corners, if it faces the viewer; `shade` (0..1) +/// darkens it. Seen from behind it is left out unless `back` is set (then drawn mirrored). +pub(crate) fn card(cam: &Cam, source: Source, src: R, c: [V3; 4], alpha: f64, shade: f64, back: bool) -> Vec { + let q = cam.quad(c); + if !q.iter().all(|p| p[0].is_finite() && p[1].is_finite()) { + return vec![]; + } + let front = area(&q) > 0.0; + if !front && !back { + return vec![]; + } + let l = Layer::quad(source, src, q, alpha); + let mut v = vec![l]; + if shade > 0.001 { + v.push(l.shade(shade)); + } + v +} + +/// How much a card facing along `n` (unit normal toward the viewer is (0, 0, −1)) is darkened by +/// the light behind the viewer: 0 facing straight on. +pub(crate) fn facing_shade(c: [V3; 4], k: f64) -> f64 { + let u = c[1].sub(c[0]); + let w = c[3].sub(c[0]); + let n = v3(u.y * w.z - u.z * w.y, u.z * w.x - u.x * w.z, u.x * w.y - u.y * w.x); + let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt(); + if len < 1e-12 { + return 0.0; + } + let cos = (n.z / len).abs(); + (k * (1.0 - cos)).clamp(0.0, 1.0) +} diff --git a/crates/anim/src/transition_tests.rs b/crates/anim/src/transition_tests.rs new file mode 100644 index 0000000..6627306 --- /dev/null +++ b/crates/anim/src/transition_tests.rs @@ -0,0 +1,230 @@ +//! Transition curves and geometry against values measured on PowerPoint's video export +//! (`plan/powerpoint/tools/synth_transitions.ps1`, 2 s transitions, 30 fps). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use deckcraft_model::anim::TRANSITIONS; + +use crate::transition::{Layer, Source, transition_frame, transition_layers, uses_slide_parts}; +use crate::transition_geom::{edge, pow_inout, q2, ss}; + +const A: f64 = 16.0 / 9.0; + +fn near(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +fn of(ls: &[Layer], src: Source) -> Vec<&Layer> { + ls.iter().filter(|l| l.source == src).collect() +} + +fn left(l: &Layer) -> f64 { + l.quad.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min) +} + +fn top(l: &Layer) -> f64 { + l.quad.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min) +} + +#[test] +fn fade_follows_powerpoints_s_curve() { + // Measured share of the new slide in a 2 s Fade at t = 0.25, 0.5, 0.75: 0.155, 0.49, 0.82. + for (t, want) in [(0.25, 0.155), (0.5, 0.49), (0.75, 0.82)] { + let ls = transition_layers("fade", "smoothly", t); + let new = of(&ls, Source::New); + assert!(near(new[0].alpha, want, 0.04), "t={t}: {}", new[0].alpha); + } + // Through black: the old slide is gone to black at the middle, then the new one fades in. + let mid = transition_layers("fade", "throughBlack", 0.5); + assert!(mid.iter().all(|l| l.source == Source::Black)); + let q = transition_layers("fade", "throughBlack", 0.25); + assert!(near(of(&q, Source::Old)[0].alpha, 0.5, 0.01)); +} + +#[test] +fn push_moves_both_slides_with_an_eased_curve() { + for (o, dx, dy) in [("l", -1.0, 0.0), ("r", 1.0, 0.0), ("u", 0.0, -1.0), ("d", 0.0, 1.0)] { + for t in [0.25, 0.5, 0.75] { + let ls = transition_layers("push", o, t); + let (old, new) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]); + let s = q2(t); + assert!(near(old.quad[0][0], dx * s, 1e-9) && near(old.quad[0][1], dy * s, 1e-9), "{o} {t}"); + assert!(near(new.quad[0][0], dx * (s - 1.0), 1e-9) && near(new.quad[0][1], dy * (s - 1.0), 1e-9), "{o} {t}"); + } + } + // Ease in-out: slow at both ends. + assert!(q2(0.1) < 0.05 && q2(0.9) > 0.95 && near(q2(0.5), 0.5, 1e-9)); +} + +#[test] +fn cover_and_uncover_match_measured_positions() { + // Offset of the new slide in Cover Left (fraction of the width) at measured times. + for (t, off) in [(0.445, 0.655), (0.495, 0.512), (0.544, 0.359), (0.643, 0.134), (0.742, 0.046)] { + let ls = transition_layers("cover", "l", t); + assert!(near(left(of(&ls, Source::New)[0]), off, 0.05), "cover t={t}"); + } + // Offset of the old slide in Uncover Left. + for (t, off) in [(0.244, -0.06), (0.343, -0.14), (0.442, -0.32), (0.508, -0.5), (0.558, -0.63)] { + let ls = transition_layers("uncover", "l", t); + assert!(near(left(of(&ls, Source::Old)[0]), off, 0.05), "uncover t={t}"); + } + // Diagonal Cover shoots in and settles slowly. + for (t, off) in [(0.142, 0.728), (0.257, 0.49), (0.356, 0.29), (0.455, 0.146), (0.604, 0.053)] { + let ls = transition_layers("cover", "lu", t); + let n = of(&ls, Source::New)[0]; + assert!(near(left(n), off, 0.06) && near(top(n), off, 0.06), "cover lu t={t}"); + } + assert!(near(pow_inout(0.25, 3.0), 0.0625, 1e-9)); +} + +#[test] +fn wipe_has_a_wide_soft_edge() { + // Measured along a row of Wipe Right at t = 0.5: about half-way through at x = 0.6, nearly + // nothing at the right edge, fully new on the left third. + let c = -0.4 + 1.8 * 1.05 * 0.5; + assert!(near(edge(c, 0.6, 0.8), 0.49, 0.1)); + assert!(edge(c, 1.0, 0.8) < 0.1); + assert!(edge(c, 0.2, 0.8) > 0.8); + // Directions: "r" starts on the left, "l" on the right, "u" at the bottom, "d" at the top, + // diagonals at the opposite corner. + let first = |o: &str| { + let ls = transition_layers("wipe", o, 0.15); + let n = of(&ls, Source::New); + assert!(!n.is_empty(), "{o}"); + let cx = n.iter().map(|l| (l.quad[0][0] + l.quad[2][0]) / 2.0).sum::() / n.len() as f64; + let cy = n.iter().map(|l| (l.quad[0][1] + l.quad[2][1]) / 2.0).sum::() / n.len() as f64; + (cx, cy) + }; + assert!(first("r").0 < 0.3 && first("l").0 > 0.7); + assert!(first("u").1 > 0.7 && first("d").1 < 0.3); + let ru = first("ru"); + assert!(ru.0 < 0.4 && ru.1 > 0.6, "{ru:?}"); +} + +#[test] +fn split_and_shape_open_from_the_middle() { + for o in ["horzOut", "vertOut"] { + let ls = transition_layers("split", o, 0.2); + let n = of(&ls, Source::New); + assert!(n.iter().all(|l| { + let c = [(l.quad[0][0] + l.quad[2][0]) / 2.0, (l.quad[0][1] + l.quad[2][1]) / 2.0]; + if o.starts_with("horz") { (c[1] - 0.5).abs() < 0.4 } else { (c[0] - 0.5).abs() < 0.4 } + })); + } + // Shape In is done with the old slide at half time; Shape Out starts the new one then. + assert!(of(&transition_layers("shape", "in", 0.55), Source::Old).is_empty()); + assert!(of(&transition_layers("shape", "out", 0.45), Source::New).is_empty()); +} + +#[test] +fn dynamic_content_curves() { + // Pan: the new content's offset (fraction of the width) at measured times. + for (t, off) in [(0.6, 0.134), (0.7, 0.086), (0.8, 0.039), (0.9, 0.011)] { + let ls = transition_layers("pan", "l", t); + assert!(near(left(of(&ls, Source::NewContent)[0]), off, 0.07), "pan t={t}"); + } + // The backgrounds cross-fade under the moving contents. + let ls = transition_layers("pan", "l", 0.5); + assert_eq!(ls.first().map(|l| l.source), Some(Source::OldBack)); + assert!(of(&ls, Source::NewBack)[0].alpha > 0.5); + // Ferris Wheel: the old content drops (top edge, slide heights) as measured. + for (t, dy) in [(0.147, 0.06), (0.246, 0.17), (0.345, 0.36), (0.444, 0.70)] { + let ls = transition_layers("ferris", "l", t); + let q = of(&ls, Source::OldContent)[0].quad; + let cy = q.iter().map(|p| p[1]).sum::() / 4.0 - 0.5; + assert!(near(cy, dy, 0.08), "ferris t={t}: {cy}"); + } + for k in ["pan", "ferris", "conveyor", "rotate", "window", "orbit", "flythrough"] { + assert!(uses_slide_parts(k, ""), "{k}"); + } + assert!(uses_slide_parts("reveal", "smoothlyLeft") && !uses_slide_parts("reveal", "blackLeft")); + assert!(!uses_slide_parts("cube", "l") && !uses_slide_parts("push", "l")); +} + +#[test] +fn cube_turns_toward_the_option() { + // Cube Left at the middle: the shared edge is near the middle, the old face on the left. + let ls = transition_frame("cube", "l", 0.5, A); + let (o, n) = (of(&ls, Source::Old)[0], of(&ls, Source::New)[0]); + let o_right = o.quad.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max); + assert!(near(o_right, left(n), 0.02), "shared edge: {o_right} vs {}", left(n)); + assert!(o_right > 0.3 && o_right < 0.7); + // Up: the new face comes from below. + let ls = transition_frame("cube", "u", 0.5, A); + assert!(top(of(&ls, Source::New)[0]) > top(of(&ls, Source::Old)[0])); +} + +#[test] +fn switch_swaps_the_cards_past_halfway() { + let order = |t: f64| { + let ls = transition_frame("switch", "l", t, A); + let pos = |s: Source| ls.iter().position(|l| l.source == s); + (pos(Source::Old), pos(Source::New)) + }; + let (o, n) = order(0.3); + assert!(o > n, "old in front early"); + let (o, n) = order(0.8); + assert!(n > o, "new in front late"); +} + +#[test] +fn clock_hand_sweeps_at_constant_speed() { + // The share of the slide covered grows linearly. + let area = |t: f64| { + let ls = transition_layers("clock", "clockwise", t); + of(&ls, Source::New) + .iter() + .map(|l| { + let [a, b, c, _] = l.quad; + ((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])).abs() / 2.0 * (l.fade[0] + l.fade[1] + l.fade[2]) / 3.0 + }) + .sum::() + }; + let (a1, a2, a3) = (area(0.25), area(0.5), area(0.75)); + assert!(near(a2 - a1, a3 - a2, 0.08), "{a1} {a2} {a3}"); + assert!(near(a2, 0.5, 0.08), "{a2}"); +} + +#[test] +fn options_change_the_frame() { + // Every option of a transition draws something different from its other options midway. + for (id, _, _, _, opts) in TRANSITIONS { + if matches!(*id, "morph" | "none" | "random") || opts.len() < 2 { + continue; + } + for (i, a) in opts.iter().enumerate() { + for b in opts.iter().skip(i + 1) { + let differs = [0.2, 0.35, 0.5, 0.8].iter().any(|t| transition_layers(id, a, *t) != transition_layers(id, b, *t)); + assert!(differs, "{id}: {a} vs {b}"); + } + } + } +} + +#[test] +fn frames_stay_cheap() { + // Per frame, the show draws these meshes from cached slide textures: keep them small. + for (id, _, _, _, opts) in TRANSITIONS { + let mut options: Vec<&str> = opts.to_vec(); + options.push(""); + for o in options { + for t in [0.1, 0.3, 0.5, 0.7, 0.9] { + let tris: usize = transition_frame(id, o, t, A).iter().map(|l| crate::layer_mesh(l).1.len()).sum(); + assert!(tris < 30_000, "{id}/{o} t={t}: {tris} triangles"); + } + } + } +} + +#[test] +fn hostile_aspects_are_safe() { + for a in [f64::NAN, 0.0, -3.0, 1e9, f64::INFINITY] { + for (id, ..) in TRANSITIONS { + for l in transition_frame(id, "", 0.4, a) { + assert!(l.quad.iter().all(|p| p[0].is_finite() && p[1].is_finite())); + assert!(l.fade.iter().all(|f| (0.0..=1.0).contains(f))); + } + } + } + assert!(near(ss(0.5), 0.5, 1e-12)); +} diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs index 3495d21..7c45aa0 100644 --- a/crates/model/src/anim.rs +++ b/crates/model/src/anim.rs @@ -63,7 +63,7 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ ("fracture", "Fracture", "Exciting", 2000, &[]), ("crush", "Crush", "Exciting", 2000, &[]), ("peelOff", "Peel Off", "Exciting", 1500, &["l", "r"]), - ("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r"]), + ("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r", "singleL", "singleR"]), ("airplane", "Airplane", "Exciting", 2000, &["l", "r"]), ("origami", "Origami", "Exciting", 2000, &["l", "r"]), ("dissolve", "Dissolve", "Exciting", 1200, &[]), @@ -72,9 +72,9 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ ("clock", "Clock", "Exciting", 2000, &["clockwise", "counterClockwise", "wedge"]), ("ripple", "Ripple", "Exciting", 1400, &["center", "lu", "ru", "ld", "rd"]), ("honeycomb", "Honeycomb", "Exciting", 3000, &[]), - ("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d"]), + ("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d", "hexL", "hexR", "hexU", "hexD"]), ("vortex", "Vortex", "Exciting", 3000, &["l", "r", "u", "d"]), - ("shred", "Shred", "Exciting", 2000, &["strips", "particles"]), + ("shred", "Shred", "Exciting", 2000, &["stripsIn", "stripsOut", "particlesIn", "particlesOut"]), ("switch", "Switch", "Exciting", 1250, &["l", "r"]), ("flip", "Flip", "Exciting", 1250, &["l", "r"]), ("gallery", "Gallery", "Exciting", 1500, &["l", "r"]), @@ -82,7 +82,7 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ ("doors", "Doors", "Exciting", 1400, &["vert", "horz"]), ("box", "Box", "Exciting", 1250, &["l", "r", "u", "d"]), ("comb", "Comb", "Exciting", 1000, &["horz", "vert"]), - ("zoom", "Zoom", "Exciting", 1000, &["in", "out"]), + ("zoom", "Zoom", "Exciting", 1000, &["in", "out", "rotate"]), ("random", "Random", "Exciting", 2000, &[]), ("pan", "Pan", "Dynamic Content", 1000, &["u", "d", "l", "r"]), ("ferris", "Ferris Wheel", "Dynamic Content", 2000, &["l", "r"]), @@ -93,6 +93,101 @@ pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ ("flythrough", "Fly Through", "Dynamic Content", 1000, &["in", "out", "inBounce", "outBounce"]), ]; +/// The Effect Options label PowerPoint shows for a transition option. Options are stored as the +/// file format's values: for moving transitions (Push, Wipe, Cover, Pan, Cube…) the direction +/// things move, so "l" is the effect coming "From Right"; Glitter and the preset effects (Fall +/// Over, Wind…) name the side itself; Ripple names where the wave heads. +pub fn transition_option_label(kind: &str, option: &str) -> String { + let side = |c: char| match c { + 'l' => "Left", + 'r' => "Right", + 'u' => "Top", + _ => "Bottom", + }; + let opposite = |c: char| match c { + 'l' => 'r', + 'r' => 'l', + 'u' => 'd', + _ => 'u', + }; + let corner = |o: &str, flip: bool| -> Option { + let mut cs = o.chars(); + let (h, v) = (cs.next()?, cs.next()?); + let (h, v) = if flip { (opposite(h), opposite(v)) } else { (h, v) }; + Some(format!("From {}-{}", side(v), side(h))) + }; + let moving = matches!( + kind, + "push" + | "wipe" + | "cover" + | "uncover" + | "pan" + | "vortex" + | "cube" + | "box" + | "rotate" + | "orbit" + | "gallery" + | "conveyor" + | "ferris" + | "switch" + | "flip" + ); + match (kind, option) { + (_, "horz") => "Horizontal".into(), + (_, "vert") => "Vertical".into(), + ("split", o) => format!("{} {}", if o.starts_with("horz") { "Horizontal" } else { "Vertical" }, if o.ends_with("In") { "In" } else { "Out" }), + ("fade" | "cut", "throughBlack") => "Through Black".into(), + ("fade", _) => "Smoothly".into(), + ("cut", _) => "Cut".into(), + ("clock", "counterClockwise") => "Counterclockwise".into(), + ("clock", "wedge") => "Wedge".into(), + ("clock", _) => "Clockwise".into(), + ("zoom", "rotate") => "Zoom and Rotate".into(), + ("ripple", "center") => "Center".into(), + ("ripple", o) => corner(o, true).unwrap_or_else(|| "Center".into()), + ("glitter", o) => { + let (pat, d) = match o.strip_prefix("hex") { + Some(d) => ("Hexagons", d.to_ascii_lowercase()), + None => ("Diamonds", o.to_string()), + }; + format!("{pat} From {}", side(d.chars().next().unwrap_or('l'))) + } + ("pageCurlDouble", o) => match o { + "singleL" => "Single Left".into(), + "singleR" => "Single Right".into(), + "r" => "Double Right".into(), + _ => "Double Left".into(), + }, + ("shred", o) => { + format!("{} {}", if o.starts_with("particles") { "Particles" } else { "Strips" }, if o.ends_with("Out") { "Out" } else { "In" }) + } + ("reveal", o) => format!( + "{} {}", + if o.starts_with("black") { "Through Black" } else { "Smoothly" }, + if o.ends_with("Right") { "From Right" } else { "From Left" } + ), + ("flythrough", o) => { + format!("{}{}", if o.starts_with("out") { "Out" } else { "In" }, if o.ends_with("Bounce") { " with Bounce" } else { "" }) + } + ("morph", o) => match o { + "words" => "Words".into(), + "characters" => "Characters".into(), + _ => "Objects".into(), + }, + (_, o) if o.len() == 2 && moving => corner(o, true).unwrap_or_default(), + (_, o) if o.len() == 1 && moving => format!("From {}", side(opposite(o.chars().next().unwrap_or('l')))), + (_, "l") => "Left".into(), + (_, "r") => "Right".into(), + (_, "u") => "Top".into(), + (_, "d") => "Bottom".into(), + (_, "in") => "In".into(), + (_, "out") => "Out".into(), + (_, o) => o.to_string(), + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AnimClass { @@ -291,3 +386,29 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static (&'static str, &'static str, AnimClass, u32, u32, &'static [&'static str])> { ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect)) } + +#[cfg(test)] +mod option_label_tests { + use super::*; + + #[test] + fn transition_option_labels() { + assert_eq!(transition_option_label("push", "l"), "From Right"); + assert_eq!(transition_option_label("push", "u"), "From Bottom"); + assert_eq!(transition_option_label("wipe", "r"), "From Left"); + assert_eq!(transition_option_label("cover", "ru"), "From Bottom-Left"); + assert_eq!(transition_option_label("ripple", "ru"), "From Bottom-Left"); + assert_eq!(transition_option_label("glitter", "hexU"), "Hexagons From Top"); + assert_eq!(transition_option_label("glitter", "l"), "Diamonds From Left"); + assert_eq!(transition_option_label("wind", "l"), "Left"); + assert_eq!(transition_option_label("pageCurlDouble", "singleR"), "Single Right"); + assert_eq!(transition_option_label("shred", "particlesOut"), "Particles Out"); + assert_eq!(transition_option_label("zoom", "rotate"), "Zoom and Rotate"); + assert_eq!(transition_option_label("split", "vertIn"), "Vertical In"); + for (id, _, _, _, opts) in TRANSITIONS { + for o in opts.iter() { + assert!(!transition_option_label(id, o).is_empty(), "{id}/{o}"); + } + } + } +} diff --git a/crates/pptx/src/maps.rs b/crates/pptx/src/maps.rs index c700d8e..dba4b5c 100644 --- a/crates/pptx/src/maps.rs +++ b/crates/pptx/src/maps.rs @@ -17,7 +17,12 @@ pub fn transition_from_xml(e: &El) -> Option<(String, String)> { Some((k, if has_opts { o } else { String::new() })) } +/// Preset transitions (`p15:prstTrans`) whose "Left" option PowerPoint writes with `invX="1"` +/// (the others write it for "Right"), as observed on PowerPoint's own files. +const INVX_LEFT: &[&str] = &["wind", "airplane", "origami"]; + fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { + // Defaults are what PowerPoint leaves out: e.g. it writes Push Left as ``. let dir = |d: &str| e.attr("dir").unwrap_or(d).to_string(); let k = |s: &str| s.to_string(); Some(match e.local() { @@ -25,16 +30,22 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { "cut" => (k("cut"), if e.bool("thruBlk") == Some(true) { k("throughBlack") } else { String::new() }), "push" => (k("push"), dir("l")), "wipe" => (k("wipe"), dir("l")), + // Diagonal wipes. + "strips" => (k("wipe"), dir("lu")), "split" => { let o = e.attr("orient").unwrap_or("horz"); let d = e.attr("dir").unwrap_or("out"); (k("split"), format!("{o}{}", if d == "in" { "In" } else { "Out" })) } - "randomBar" => (k("randomBar"), dir("vert")), + "randomBar" => (k("randomBar"), dir("horz")), "circle" => (k("shape"), k("circle")), "diamond" => (k("shape"), k("diamond")), "plus" => (k("shape"), k("plus")), - "zoom" => (k("zoom"), dir("in")), + // Shape In / Out (the slide zooming in a box). + "zoom" => (k("shape"), if e.attr("dir") == Some("in") { k("in") } else { k("out") }), + // The Zoom transition, and its Zoom and Rotate option. + "warp" => (k("zoom"), if e.attr("dir") == Some("in") { k("in") } else { k("out") }), + "newsflash" => (k("zoom"), k("rotate")), "pull" => (k("uncover"), dir("l")), "cover" => (k("cover"), dir("l")), "checker" => (k("checker"), dir("horz")), @@ -47,14 +58,22 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { "switch" => (k("switch"), dir("r")), "flip" => (k("flip"), dir("r")), "ripple" => (k("ripple"), dir("center")), - "doors" => (k("doors"), dir("vert")), - "window" => (k("window"), dir("vert")), + "doors" => (k("doors"), dir("horz")), + "window" => (k("window"), dir("horz")), "ferris" => (k("ferris"), dir("l")), "gallery" => (k("gallery"), dir("l")), "conveyor" => (k("conveyor"), dir("l")), - "pan" => (k("pan"), dir("d")), - "glitter" => (k("glitter"), dir("l")), - "shred" => (k("shred"), if e.attr("pattern") == Some("crush") { k("particles") } else { k("strips") }), + "pan" => (k("pan"), dir("l")), + "glitter" => { + let d = dir("l"); + if e.attr("pattern") == Some("hexagon") { (k("glitter"), format!("hex{}", d.to_ascii_uppercase())) } else { (k("glitter"), d) } + } + "shred" => { + // `crush` is what DeckCraft wrote before it knew PowerPoint's `rectangle`. + let particles = matches!(e.attr("pattern"), Some("rectangle" | "crush")); + let out = e.attr("dir") == Some("out"); + (k("shred"), format!("{}{}", if particles { "particles" } else { "strips" }, if out { "Out" } else { "In" })) + } "reveal" => { let black = e.bool("thruBlk") == Some(true); let right = e.attr("dir") == Some("r"); @@ -78,11 +97,16 @@ fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { } "prstTrans" => { let prst = e.attr("prst").unwrap_or("fallOver"); + let inv = e.bool("invX") == Some(true); + if prst == "pageCurlSingle" { + return Some((k("pageCurlDouble"), if inv { k("singleR") } else { k("singleL") })); + } let known = deckcraft_model::anim::TRANSITIONS.iter().any(|t| t.0 == prst); if !known { return None; } - (k(prst), if e.bool("invX") == Some(true) { k("r") } else { k("l") }) + let left = if INVX_LEFT.contains(&prst) { inv } else { !inv }; + (k(prst), if left { k("l") } else { k("r") }) } "morph" => ( k("morph"), @@ -119,7 +143,10 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static "fade" => w.empty("p:fade", A::new().t("thruBlk", option == "throughBlack")), "cut" => w.empty("p:cut", A::new().t("thruBlk", option == "throughBlack")), "push" => w.empty("p:push", A::new().a("dir", lrud("u"))), - "wipe" => w.empty("p:wipe", A::new().a("dir", lrud("r"))), + "wipe" => match option { + "lu" | "ru" | "ld" | "rd" => w.empty("p:strips", A::new().a("dir", option)), + _ => w.empty("p:wipe", A::new().a("dir", lrud("r"))), + }, "split" => { let (o, d) = match option { "vertIn" => ("vert", "in"), @@ -136,7 +163,13 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static "in" | "out" => w.empty("p:zoom", A::new().a("dir", option)), _ => w.empty0("p:circle"), }, - "zoom" => w.empty("p:zoom", A::new().a("dir", if option == "out" { "out" } else { "in" })), + "zoom" => match option { + "rotate" => w.empty0("p:newsflash"), + _ => { + w.empty("p14:warp", A::new().a("dir", if option == "out" { "out" } else { "in" })); + return Some("p14"); + } + }, "uncover" => w.empty("p:pull", A::new().a("dir", eight("l"))), "cover" => w.empty("p:cover", A::new().a("dir", eight("l"))), "checker" => w.empty("p:checker", A::new().a("dir", hv("horz"))), @@ -160,9 +193,15 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static w.empty0("p14:flash"); return Some("p14"); } - "vortex" | "glitter" => { - let name = if kind == "vortex" { "p14:vortex" } else { "p14:glitter" }; - w.empty(name, A::new().a("dir", lrud("l"))); + "vortex" => { + w.empty("p14:vortex", A::new().a("dir", lrud("l"))); + return Some("p14"); + } + "glitter" => { + let hex = option.starts_with("hex"); + let d = option.trim_start_matches("hex").to_ascii_lowercase(); + let d = if matches!(d.as_str(), "l" | "r" | "u" | "d") { d } else { "l".to_string() }; + w.empty("p14:glitter", A::new().a("dir", d).o("pattern", hex.then_some("hexagon"))); return Some("p14"); } "switch" | "flip" | "gallery" | "conveyor" | "ferris" => { @@ -194,7 +233,11 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static return Some("p14"); } "shred" => { - w.empty("p14:shred", A::new().a("pattern", if option == "particles" { "crush" } else { "strip" })); + let particles = option.starts_with("particles"); + w.empty( + "p14:shred", + A::new().a("pattern", if particles { "rectangle" } else { "strip" }).a("dir", if option.ends_with("Out") { "out" } else { "in" }), + ); return Some("p14"); } "reveal" => { @@ -215,8 +258,13 @@ pub fn transition_to_xml(w: &mut W, kind: &str, option: &str) -> Option<&'static ); return Some("p14"); } + "pageCurlDouble" if option.starts_with("single") => { + w.empty("p15:prstTrans", A::new().a("prst", "pageCurlSingle").t("invX", option == "singleR")); + return Some("p15"); + } "fallOver" | "drape" | "curtains" | "wind" | "prestige" | "fracture" | "crush" | "peelOff" | "pageCurlDouble" | "airplane" | "origami" => { - w.empty("p15:prstTrans", A::new().a("prst", kind).t("invX", option == "r")); + let inv = if INVX_LEFT.contains(&kind) { option == "l" } else { option == "r" }; + w.empty("p15:prstTrans", A::new().a("prst", kind).t("invX", inv)); return Some("p15"); } "morph" => { @@ -342,16 +390,7 @@ mod tests { assert!(!ns.is_empty()); let d = parse(w.s.as_bytes()).unwrap(); let (k, opt) = transition_from_xml(&d.root).unwrap_or_else(|| panic!("no read for {kind}")); - // `shape` in/out are written as zoom; everything else must come back. - if *kind == "shape" && (o == "in" || o == "out") { - assert_eq!(k, "zoom"); - continue; - } assert_eq!(k, *kind, "kind for {kind}/{o}"); - // PresentationML wipes have no diagonal directions. - if *kind == "wipe" && o.len() == 2 { - continue; - } if !o.is_empty() { assert_eq!(opt, o, "option for {kind}"); } diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index c9f33fe..eb194b8 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -857,3 +857,135 @@ fn wordart_warps_and_their_adjusts_round_trip() { assert_eq!(inflate.warp.as_deref(), Some("textInflate")); assert!(inflate.warp_adj.is_empty()); } + +/// Slide XML of a deck exported with one slide per transition (kind, option, ms, advance after). +fn transition_deck(list: &[(&str, &str, u32, Option)]) -> (Presentation, Vec) { + let mut p = defaults::new_presentation(None); + let base = p.slides[0].as_ref().clone(); + p.slides.clear(); + for (kind, option, ms, adv) in list { + let mut s = base.clone(); + s.id = SlideId(p.alloc_id()); + s.transition = Some(Transition { + kind: kind.to_string(), + option: option.to_string(), + duration_ms: *ms, + advance_on_click: adv.is_none(), + advance_after_ms: *adv, + ..Default::default() + }); + p.slides.push(Arc::new(s)); + } + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let xml = (1..=list.len()) + .map(|i| { + let mut x = String::new(); + std::io::Read::read_to_string(&mut zip.by_name(&format!("ppt/slides/slide{i}.xml")).expect("slide"), &mut x).expect("read"); + x + }) + .collect(); + (p, xml) +} + +#[test] +fn transition_xml_matches_powerpoints_elements() { + // The elements, attributes and namespaces PowerPoint itself writes for these effect options + // (observed on files it saved), inside `mc:AlternateContent` with a `p:fade` fallback for + // the 2010+ effects. + let cases: &[(&str, &str, &[&str], &str)] = &[ + ("wipe", "ru", &[r#""#], "p14"), + ("wipe", "u", &[r#""#], "p14"), + ("zoom", "in", &[r#""#], "p14"), + ("zoom", "rotate", &[""], "p14"), + ("shape", "out", &[r#""#], "p14"), + ("glitter", "hexD", &[r#""#], "p14"), + ("shred", "particlesOut", &[r#""#], "p14"), + ("wind", "l", &[r#""#], "p15"), + ("wind", "r", &[r#""#], "p15"), + ("fallOver", "r", &[r#""#], "p15"), + ("pageCurlDouble", "singleR", &[r#""#], "p15"), + ("orbit", "u", &[r#""#], "p14"), + ("vortex", "r", &[r#""#], "p14"), + ("morph", "words", &[r#""#], "p159"), + ]; + let list: Vec<(&str, &str, u32, Option)> = cases.iter().map(|c| (c.0, c.1, 2000, Some(1000))).collect(); + let (p, xml) = transition_deck(&list); + for ((kind, option, wants, req), x) in cases.iter().zip(&xml) { + for w in *wants { + assert!(x.contains(w), "{kind}/{option}: {w} in {x}"); + } + assert!(x.contains(&format!(r#"Requires="{req}""#)), "{kind}/{option}"); + assert!(x.contains(r#"p14:dur="2000""#) && x.contains(r#"advTm="1000""#) && x.contains(r#"advClick="0""#), "{kind}/{option}"); + assert!(x.contains(""), "{kind}/{option}"); + } + // Back in, everything is as it was. + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + for ((kind, option, ..), s) in cases.iter().zip(&q.slides) { + let t = s.transition.as_ref().expect("transition"); + assert_eq!( + (t.kind.as_str(), t.option.as_str(), t.duration_ms, t.advance_after_ms, t.advance_on_click), + (*kind, *option, 2000, Some(1000), false) + ); + } +} + +#[test] +fn transition_defaults_read_like_powerpoint() { + // PowerPoint leaves out an option when it is the element's default; these are its defaults. + let cases: &[(&str, &str, &str)] = &[ + ("", "push", "l"), + ("", "wipe", "l"), + ("", "wipe", "lu"), + ("", "randomBar", "horz"), + ("", "checker", "horz"), + ("", "blinds", "horz"), + ("", "comb", "horz"), + ("", "shape", "out"), + (r#""#, "shape", "in"), + ("", "split", "horzOut"), + ("", "doors", "horz"), + ("", "window", "horz"), + ("", "pan", "l"), + ("", "zoom", "out"), + ("", "glitter", "l"), + ("", "shred", "stripsIn"), + ("", "reveal", "smoothlyLeft"), + ("", "cube", "l"), + (r#""#, "airplane", "r"), + (r#""#, "peelOff", "l"), + (r#""#, "pageCurlDouble", "singleL"), + ]; + let (_, xml) = transition_deck(&[("fade", "smoothly", 2000, None)]); + let Some(x) = xml.first() else { panic!("no slide") }; + let start = x.find("").expect("transition end") + "".len(); + let p14 = r#"xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main""#; + let p15 = r#"xmlns:p15="http://schemas.microsoft.com/office/powerpoint/2012/main""#; + for (el, kind, option) in cases { + let tr = format!(r#"{el}"#); + let edited = format!("{}{}{}", &x[..start], tr, &x[end..]); + let mut p = defaults::new_presentation(None); + let base = p.slides[0].as_ref().clone(); + p.slides = vec![Arc::new(base)]; + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + std::io::Read::read_to_end(&mut f, &mut data).expect("read"); + if name == "ppt/slides/slide1.xml" { + data = edited.clone().into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + std::io::Write::write_all(&mut out, &data).expect("write"); + } + let bytes = out.finish().expect("finish").into_inner(); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let t = q.slides[0].transition.as_ref().expect("transition"); + assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); + } +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 6b66288..2704231 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -13,6 +13,7 @@ deckcraft-geom = { workspace = true } deckcraft-color = { workspace = true } deckcraft-metafile = { workspace = true } deckcraft-model = { workspace = true } +deckcraft-anim = { workspace = true } deckcraft-fonts = { workspace = true } deckcraft-text = { workspace = true } vello_cpu = { workspace = true } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 47a0f1e..1621f05 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -23,6 +23,7 @@ mod pathgrad; mod placed; mod ramp; mod table; +mod transition; mod warp; use std::collections::HashMap; @@ -41,12 +42,14 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::{RenderContext, Resources, peniko}; +pub use deckcraft_anim::{transition_frame, transition_layers, uses_slide_parts}; pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; pub use table::row_heights as table_row_heights; +pub use transition::{SlideParts, composite_transition, render_transition}; /// Largest pixmap side we render (vello_cpu uses u16 sizes). pub const MAX_SIDE: u32 = 16_000; @@ -329,6 +332,30 @@ pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) } } +/// Which part of a slide [`render_slide_part`] draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SlidePart { + /// The background fill with the master and layout graphics. + Backdrop, + /// The slide's own shapes over transparency. + Content, +} + +/// Render one part of slide `index` (transitions that move the contents over the backgrounds). +/// A panic inside the renderer becomes a transparent image (logged), never a crash. +pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { + let Some(slide) = pres.slides.get(index) else { return Image::default() }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide_part(pres, slide, index, part, opts)))) { + Ok(img) => img, + Err(_) => { + log::error!("rendering part of slide {index} panicked; showing nothing"); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + let (w, h) = output_size(pres, opts); + Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] } + } + } +} + /// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the /// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by /// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`: @@ -550,6 +577,25 @@ impl Renderer { self.finish(ctx, w, h) } + fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); + let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; + if let Some(rctx) = Ctx::for_slide(pres, slide) { + match part { + SlidePart::Backdrop => self.backdrop(&mut ctx, &f, &rctx, slide, view), + SlidePart::Content => { + // Shapes filled with the background (`useBgFill`) still show it. + let (fill, ph) = resolve::background(&rctx, Some(slide)); + self.bg = Some((fill, ph, view)); + for s in &slide.shapes { + self.shape(&mut ctx, &f, &rctx, s, view, 0); + } + } + } + } + self.finish(ctx, w, h) + } + #[allow(clippy::too_many_arguments)] fn blend( &mut self, diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs new file mode 100644 index 0000000..0585144 --- /dev/null +++ b/crates/render/src/transition.rs @@ -0,0 +1,517 @@ +//! Slide transition frames without a GPU: the layers of [`deckcraft_anim::transition_layers`] +//! composited on the CPU from two rendered slide images. The slide show draws the same layers with +//! the UI toolkit's meshes; this is the headless twin used by the CLI, comparisons and tests. + +use deckcraft_anim::{Layer, Source, layer_mesh, transition_frame, uses_slide_parts}; +use deckcraft_model::{Presentation, ShapeId}; + +use crate::{Image, RenderOpts, ShapeState, SlidePart, render_blend, render_slide, render_slide_part}; + +/// The two slides of a transition rendered as background and content, for the transitions that +/// move contents over the backgrounds ([`deckcraft_anim::uses_slide_parts`]). `None` parts of the +/// old slide (the show's first slide) are black and nothing. +#[derive(Clone, Debug, Default)] +pub struct SlideParts { + pub old_back: Option, + pub old_content: Option, + pub new_back: Image, + pub new_content: Image, +} + +impl SlideParts { + /// Render the parts of slides `old` and `new`. + pub fn render(pres: &Presentation, old: Option, new: usize, opts: &RenderOpts) -> SlideParts { + SlideParts { + old_back: old.map(|i| render_slide_part(pres, i, SlidePart::Backdrop, opts)), + old_content: old.map(|i| render_slide_part(pres, i, SlidePart::Content, opts)), + new_back: render_slide_part(pres, new, SlidePart::Backdrop, opts), + new_content: render_slide_part(pres, new, SlidePart::Content, opts), + } + } +} + +/// Composite transition `layers` over black into an image the size of `new`. `old` is the outgoing +/// slide (`None`: black, as when the show starts); `parts` the slides' backgrounds and contents +/// (without them, a background draws its whole slide and content draws nothing). +pub fn composite_transition(old: Option<&Image>, new: &Image, parts: Option<&SlideParts>, layers: &[Layer]) -> Image { + let (w, h) = (new.width, new.height); + let n = (w as usize).saturating_mul(h as usize); + let mut out = Image { width: w, height: h, pixels: [0u8, 0, 0, 255].repeat(n) }; + for l in layers { + let src = match (l.source.is_part(), parts) { + (true, None) => match l.source.whole() { + Some(s) => s, + None => continue, + }, + _ => l.source, + }; + let tex = match (src, parts) { + (Source::Old, _) => old.map(Tex::Image).unwrap_or(Tex::Solid([0.0, 0.0, 0.0])), + (Source::New, _) => Tex::Image(new), + (Source::Black, _) => Tex::Solid([0.0, 0.0, 0.0]), + (Source::White, _) => Tex::Solid([255.0, 255.0, 255.0]), + (Source::OldBack, Some(p)) => p.old_back.as_ref().map(Tex::Image).unwrap_or(Tex::Solid([0.0, 0.0, 0.0])), + (Source::NewBack, Some(p)) => Tex::Image(&p.new_back), + (Source::OldContent, Some(p)) => match &p.old_content { + Some(i) => Tex::Image(i), + None => continue, + }, + (Source::NewContent, Some(p)) => Tex::Image(&p.new_content), + (_, None) => continue, + }; + draw_layer(&mut out, l, &tex); + } + out +} + +/// The frame of slide `index`'s transition at progress `t` (0..1): the previous slide (black for +/// the first) going out, slide `index` coming in, at `opts`' scale and size. Morph frames are +/// rendered shape by shape like the show does. +pub fn render_transition(pres: &Presentation, index: usize, t: f64, opts: &RenderOpts) -> Image { + let Some(slide) = pres.slides.get(index) else { return Image::default() }; + let (kind, option) = slide.transition.as_ref().map(|tr| (tr.kind.as_str(), tr.option.as_str())).unwrap_or(("none", "")); + let prev = index.checked_sub(1).filter(|i| pres.slides.get(*i).is_some()); + if kind == "morph" + && let Some(from) = prev + && let Some(a) = pres.slides.get(from) + { + let tt = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + if tt < 1.0 { + let m = deckcraft_anim::morph_frame(pres, a, slide, tt, &|_| false); + let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; + let o = RenderOpts { state: Some(&f), ..copy_opts(opts) }; + let text: Vec = + m.text.iter().map(|x| crate::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }).collect(); + let paths: Vec = m + .paths + .iter() + .map(|x| crate::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) + .collect(); + return render_blend(pres, from, index, m.mix, &m.shapes, &text, &paths, &o); + } + } + let new = render_slide(pres, index, opts); + let old = prev.map(|i| render_slide(pres, i, opts)); + let parts = uses_slide_parts(kind, option).then(|| SlideParts::render(pres, prev, index, opts)); + let aspect = pres.slide_size.width / pres.slide_size.height.max(1.0); + composite_transition(old.as_ref(), &new, parts.as_ref(), &transition_frame(kind, option, t, aspect)) +} + +fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> { + RenderOpts { + scale: o.scale, + edit: o.edit, + show_hidden: o.show_hidden, + state: o.state, + skip: o.skip, + skip_text: o.skip_text, + gif_times: o.gif_times, + threads: o.threads, + offset: o.offset, + size: o.size, + clear: o.clear, + date_text: o.date_text.clone(), + now: o.now, + } +} + +enum Tex<'a> { + Image(&'a Image), + /// Straight RGB, opaque. + Solid([f32; 3]), +} + +impl Tex<'_> { + /// Premultiplied RGBA (0..255) at texture coordinate (u, v), bilinear, clamped to the edge. + fn sample(&self, u: f64, v: f64) -> [f32; 4] { + match self { + Tex::Solid(c) => [c[0], c[1], c[2], 255.0], + Tex::Image(img) => { + let (w, h) = (img.width as usize, img.height as usize); + if w == 0 || h == 0 { + return [0.0; 4]; + } + let x = (u * w as f64 - 0.5).clamp(0.0, (w - 1) as f64); + let y = (v * h as f64 - 0.5).clamp(0.0, (h - 1) as f64); + let (x0, y0) = (x.floor() as usize, y.floor() as usize); + let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1)); + let (fx, fy) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32); + let px = |xx: usize, yy: usize| -> [f32; 4] { + let i = (yy * w + xx) * 4; + match img.pixels.get(i..i + 4) { + Some(p) => [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32], + None => [0.0; 4], + } + }; + let (a, b, c, d) = (px(x0, y0), px(x1, y0), px(x0, y1), px(x1, y1)); + let mut o = [0.0f32; 4]; + for k in 0..4 { + let top = a[k] + (b[k] - a[k]) * fx; + let bot = c[k] + (d[k] - c[k]) * fx; + o[k] = top + (bot - top) * fy; + } + o + } + } + } +} + +fn draw_layer(out: &mut Image, l: &Layer, tex: &Tex) { + if !(l.alpha.is_finite() && l.alpha > 0.0) { + return; + } + let (verts, tris) = layer_mesh(l); + let (w, h) = (out.width as f64, out.height as f64); + for t in tris { + let (Some(a), Some(b), Some(c)) = (verts.get(t[0] as usize), verts.get(t[1] as usize), verts.get(t[2] as usize)) else { continue }; + let p = [[a.pos[0] * w, a.pos[1] * h], [b.pos[0] * w, b.pos[1] * h], [c.pos[0] * w, c.pos[1] * h]]; + let uv = [a.uv, b.uv, c.uv]; + let al = [a.a, b.a, c.a].map(|x| if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }); + if al.iter().all(|x| *x <= 0.0) { + continue; + } + fill_triangle(out, p, uv, al, tex); + } +} + +/// Rasterise one textured triangle: pixel centres inside it take the texture's colour at the +/// barycentric texture coordinate, at the interpolated opacity, blended source-over. +fn fill_triangle(out: &mut Image, p: [[f64; 2]; 3], uv: [[f64; 2]; 3], al: [f64; 3], tex: &Tex) { + let area = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]); + if !area.is_finite() || area.abs() < 1e-12 { + return; + } + let (w, h) = (out.width as i64, out.height as i64); + let minx = p.iter().map(|q| q[0]).fold(f64::INFINITY, f64::min).floor().max(0.0) as i64; + let maxx = (p.iter().map(|q| q[0]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(w); + let miny = p.iter().map(|q| q[1]).fold(f64::INFINITY, f64::min).floor().max(0.0) as i64; + let maxy = (p.iter().map(|q| q[1]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(h); + if minx >= maxx || miny >= maxy { + return; + } + let edge = |a: [f64; 2], b: [f64; 2], x: f64, y: f64| (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); + let width = out.width as usize; + for y in miny..maxy { + // Sample a hair off the pixel centre so a point on an edge shared by two triangles falls + // in exactly one of them (no double-blended seams). + let fy = y as f64 + 0.5 + 2.31e-6; + for x in minx..maxx { + let fx = x as f64 + 0.5 + 1.17e-6; + let w0 = edge(p[1], p[2], fx, fy) / area; + let w1 = edge(p[2], p[0], fx, fy) / area; + let w2 = edge(p[0], p[1], fx, fy) / area; + if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 { + continue; + } + let u = w0 * uv[0][0] + w1 * uv[1][0] + w2 * uv[2][0]; + let v = w0 * uv[0][1] + w1 * uv[1][1] + w2 * uv[2][1]; + let alpha = (w0 * al[0] + w1 * al[1] + w2 * al[2]) as f32; + if alpha <= 0.0 { + continue; + } + let s = tex.sample(u, v); + let i = (y as usize * width + x as usize) * 4; + let Some(d) = out.pixels.get_mut(i..i + 4) else { continue }; + let sa = s[3] * alpha / 255.0; + for k in 0..4 { + let src = s[k] * alpha; + let v = src + d[k] as f32 * (1.0 - sa); + d[k] = v.round().clamp(0.0, 255.0) as u8; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(w: u32, h: u32, c: [u8; 4]) -> Image { + Image { width: w, height: h, pixels: c.repeat((w * h) as usize) } + } + + #[test] + fn layers_composite_in_order() { + let old = solid(40, 20, [255, 0, 0, 255]); + let new = solid(40, 20, [0, 0, 255, 255]); + let img = composite_transition(Some(&old), &new, None, &deckcraft_anim::transition_layers("cover", "l", 0.0)); + assert_eq!(img.pixel(20, 10), [255, 0, 0, 255]); + let img = composite_transition(Some(&old), &new, None, &deckcraft_anim::transition_layers("cover", "l", 1.0)); + assert_eq!(img.pixel(20, 10), [0, 0, 255, 255]); + } + + #[test] + fn missing_old_slide_is_black() { + let new = solid(10, 10, [0, 255, 0, 255]); + let img = composite_transition(None, &new, None, &[Layer::full(Source::Old, 1.0)]); + assert_eq!(img.pixel(5, 5), [0, 0, 0, 255]); + } + + #[test] + fn half_alpha_blends() { + let old = solid(10, 10, [200, 0, 0, 255]); + let new = solid(10, 10, [0, 0, 200, 255]); + let l = [Layer::full(Source::Old, 1.0), Layer::full(Source::New, 0.5)]; + let p = composite_transition(Some(&old), &new, None, &l).pixel(5, 5); + assert!((p[0] as i32 - 100).abs() <= 1 && (p[2] as i32 - 100).abs() <= 1, "{p:?}"); + } + + // ---------- golden-ish frames on synthetic slides ---------- + + const W: u32 = 160; + const H: u32 = 90; + const OLD: [u8; 4] = [220, 40, 40, 255]; + const NEW: [u8; 4] = [30, 60, 200, 255]; + + #[derive(Clone, Copy, Debug, PartialEq)] + enum Is { + Old, + New, + Black, + White, + /// Something between (a blend). + Mix, + } + + fn classify(p: [u8; 4]) -> Is { + let d = |c: [u8; 4]| (p[0] as i32 - c[0] as i32).abs() + (p[1] as i32 - c[1] as i32).abs() + (p[2] as i32 - c[2] as i32).abs(); + let near = 40; + if d(OLD) < near { + Is::Old + } else if d(NEW) < near { + Is::New + } else if d([0, 0, 0, 255]) < near { + Is::Black + } else if d([255, 255, 255, 255]) < near { + Is::White + } else { + // A face shaded in 3-D: a darker copy of one of the slides. + let shaded = |c: [u8; 4]| { + let dot = |a: [u8; 4], b: [u8; 4]| (0..3).map(|i| a[i] as f64 * b[i] as f64).sum::(); + let k = dot(p, c) / dot(c, c); + (0.3..=1.05).contains(&k) && (0..3).all(|i| (p[i] as f64 - k * c[i] as f64).abs() < 14.0) + }; + if shaded(OLD) { + Is::Old + } else if shaded(NEW) { + Is::New + } else { + Is::Mix + } + } + } + + fn frame(kind: &str, option: &str, t: f64) -> Image { + let old = solid(W, H, OLD); + let new = solid(W, H, NEW); + composite_transition(Some(&old), &new, None, &transition_frame(kind, option, t, 16.0 / 9.0)) + } + + fn at(img: &Image, x: f64, y: f64) -> Is { + let px = ((x * W as f64) as u32).min(W - 1); + let py = ((y * H as f64) as u32).min(H - 1); + classify(img.pixel(px, py)) + } + + /// (kind, option, t, x, y, what PowerPoint shows there), read off PowerPoint's own frames. + const PROBES: &[(&str, &str, f64, f64, f64, Is)] = &[ + ("fade", "smoothly", 0.1, 0.5, 0.5, Is::Old), + ("fade", "smoothly", 0.5, 0.5, 0.5, Is::Mix), + ("fade", "smoothly", 0.9, 0.5, 0.5, Is::New), + ("fade", "throughBlack", 0.5, 0.5, 0.5, Is::Black), + ("cut", "throughBlack", 0.3, 0.5, 0.5, Is::Black), + ("cut", "", 0.3, 0.5, 0.5, Is::New), + // Push / Cover / Uncover: "l" moves things toward the left; the new slide comes from the right. + ("push", "l", 0.25, 0.97, 0.5, Is::New), + ("push", "l", 0.25, 0.1, 0.5, Is::Old), + ("push", "r", 0.25, 0.03, 0.5, Is::New), + ("push", "u", 0.25, 0.5, 0.97, Is::New), + ("push", "d", 0.25, 0.5, 0.03, Is::New), + ("cover", "l", 0.5, 0.75, 0.5, Is::New), + ("cover", "l", 0.5, 0.25, 0.5, Is::Old), + ("cover", "rd", 0.5, 0.05, 0.05, Is::New), + ("cover", "rd", 0.2, 0.97, 0.97, Is::Old), + ("uncover", "l", 0.5, 0.25, 0.5, Is::Old), + ("uncover", "l", 0.5, 0.75, 0.5, Is::New), + ("uncover", "lu", 0.5, 0.5, 0.5, Is::New), + // Wipe "r" sweeps rightward with a soft edge; "u" upward; diagonals from a corner. + ("wipe", "r", 0.5, 0.05, 0.5, Is::New), + ("wipe", "r", 0.5, 0.97, 0.5, Is::Old), + ("wipe", "l", 0.5, 0.97, 0.5, Is::New), + ("wipe", "u", 0.5, 0.5, 0.97, Is::New), + ("wipe", "u", 0.5, 0.5, 0.03, Is::Old), + ("wipe", "d", 0.5, 0.5, 0.03, Is::New), + ("wipe", "ru", 0.5, 0.03, 0.97, Is::New), + ("wipe", "ru", 0.5, 0.97, 0.03, Is::Old), + ("wipe", "r", 0.5, 0.55, 0.5, Is::Mix), + // Split: Out opens from the middle, In closes from the edges. + ("split", "horzOut", 0.5, 0.5, 0.5, Is::New), + ("split", "horzOut", 0.5, 0.5, 0.02, Is::Old), + ("split", "horzIn", 0.5, 0.5, 0.02, Is::New), + ("split", "horzIn", 0.5, 0.5, 0.5, Is::Old), + ("split", "vertOut", 0.5, 0.5, 0.5, Is::New), + ("split", "vertOut", 0.5, 0.02, 0.5, Is::Old), + ("split", "vertIn", 0.5, 0.02, 0.5, Is::New), + // Shape: grows from the centre; In shrinks the old slide over the first half, Out grows + // the new one over the second. + ("shape", "circle", 0.5, 0.5, 0.5, Is::New), + ("shape", "circle", 0.5, 0.02, 0.03, Is::Old), + ("shape", "diamond", 0.5, 0.5, 0.5, Is::New), + ("shape", "diamond", 0.5, 0.02, 0.03, Is::Old), + ("shape", "plus", 0.5, 0.5, 0.05, Is::New), + ("shape", "plus", 0.5, 0.05, 0.05, Is::Old), + ("shape", "in", 0.25, 0.5, 0.5, Is::Old), + ("shape", "in", 0.25, 0.05, 0.05, Is::New), + ("shape", "in", 0.75, 0.5, 0.5, Is::New), + ("shape", "out", 0.25, 0.5, 0.5, Is::Old), + ("shape", "out", 0.75, 0.5, 0.5, Is::New), + ("shape", "out", 0.75, 0.05, 0.05, Is::Old), + // Flash: white in the middle. + ("flash", "", 0.3, 0.5, 0.5, Is::White), + // Clock: a hand sweeping from 12 o'clock. + ("clock", "clockwise", 0.25, 0.6, 0.15, Is::New), + ("clock", "clockwise", 0.25, 0.4, 0.15, Is::Old), + ("clock", "counterClockwise", 0.25, 0.4, 0.15, Is::New), + ("clock", "counterClockwise", 0.25, 0.6, 0.15, Is::Old), + ("clock", "wedge", 0.5, 0.5, 0.05, Is::New), + ("clock", "wedge", 0.5, 0.5, 0.95, Is::Old), + // Ripple: from the centre, or from the corner opposite the option. + ("ripple", "center", 0.5, 0.5, 0.5, Is::New), + ("ripple", "center", 0.5, 0.02, 0.03, Is::Old), + ("ripple", "ru", 0.4, 0.03, 0.97, Is::New), + ("ripple", "ru", 0.4, 0.97, 0.03, Is::Old), + // Comb: the old slide's bands slide off, the bottom band first. + ("comb", "horz", 0.5, 0.5, 0.97, Is::New), + ("comb", "horz", 0.5, 0.5, 0.03, Is::Old), + // Glitter "l": the sweep starts on the left. + ("glitter", "l", 0.45, 0.03, 0.5, Is::New), + ("glitter", "l", 0.45, 0.97, 0.5, Is::Old), + ("glitter", "r", 0.45, 0.97, 0.5, Is::New), + // Vortex "l": the old slide whirls off toward the left, its right side first. + ("vortex", "l", 0.25, 0.03, 0.5, Is::Old), + ("vortex", "l", 0.97, 0.03, 0.5, Is::New), + // Cube / Box "l": the new face comes in from the right. + ("cube", "l", 0.5, 0.85, 0.5, Is::New), + ("cube", "l", 0.5, 0.15, 0.5, Is::Old), + ("cube", "r", 0.5, 0.15, 0.5, Is::New), + ("box", "l", 0.25, 0.97, 0.5, Is::New), + ("box", "l", 0.25, 0.3, 0.5, Is::Old), + ("cube", "u", 0.5, 0.5, 0.9, Is::New), + ("cube", "d", 0.5, 0.5, 0.1, Is::New), + // Switch "l": the old card is pulled out to the left, the new one to the right. + ("switch", "l", 0.25, 0.97, 0.5, Is::New), + ("switch", "l", 0.25, 0.3, 0.5, Is::Old), + ("switch", "r", 0.25, 0.03, 0.5, Is::New), + // Gallery "l": the slides travel left along the wall. + ("gallery", "l", 0.6, 0.9, 0.5, Is::New), + // Flip: the old face, then the new one. + ("flip", "l", 0.2, 0.5, 0.5, Is::Old), + ("flip", "l", 0.8, 0.5, 0.5, Is::New), + // Doors open in the middle, the new slide coming forward between them. + ("doors", "vert", 0.9, 0.5, 0.5, Is::New), + // Fall Over: the old slide tips backward, uncovering the new one at the top first. + ("fallOver", "l", 0.6, 0.5, 0.03, Is::New), + ("fallOver", "l", 0.6, 0.5, 0.97, Is::Old), + // Peel Off "l": from the bottom-right corner. + ("peelOff", "l", 0.4, 0.98, 0.98, Is::New), + ("peelOff", "l", 0.4, 0.03, 0.03, Is::Old), + ("peelOff", "r", 0.4, 0.02, 0.98, Is::New), + // Page Curl: the far half rolls up first. + ("pageCurlDouble", "l", 0.5, 0.9, 0.5, Is::New), + ("pageCurlDouble", "l", 0.5, 0.1, 0.5, Is::Old), + ("pageCurlDouble", "r", 0.5, 0.1, 0.5, Is::New), + ("pageCurlDouble", "singleL", 0.6, 0.95, 0.5, Is::New), + ("pageCurlDouble", "singleL", 0.6, 0.05, 0.5, Is::Old), + // Airplane "l": a dart with its nose to the right, hanging low. + ("airplane", "l", 0.5, 0.15, 0.1, Is::New), + ("airplane", "l", 0.5, 0.8, 0.68, Is::Old), + ("airplane", "r", 0.5, 0.2, 0.68, Is::Old), + // Origami "l": the bird flies off to the left. + ("origami", "l", 0.75, 0.97, 0.5, Is::New), + // Fracture: shards fall, the top first. + ("fracture", "", 0.6, 0.5, 0.05, Is::New), + // Crush: the old slide crumples toward the centre. + ("crush", "", 0.5, 0.03, 0.03, Is::New), + ("crush", "", 0.5, 0.45, 0.5, Is::Old), + // Curtains part in the middle. + ("curtains", "", 0.4, 0.5, 0.5, Is::New), + ("curtains", "", 0.4, 0.02, 0.5, Is::Old), + // Zoom and Rotate: the new slide spins in from the centre over the old one. + ("zoom", "rotate", 0.2, 0.03, 0.03, Is::Old), + ("zoom", "out", 0.8, 0.5, 0.5, Is::New), + ("dissolve", "", 0.02, 0.5, 0.5, Is::Old), + ("dissolve", "", 0.99, 0.5, 0.5, Is::New), + ]; + + #[test] + fn frames_match_powerpoint_probes() { + let mut bad = Vec::new(); + for (kind, option, t, x, y, want) in PROBES { + let got = at(&frame(kind, option, *t), *x, *y); + if got != *want { + bad.push(format!("{kind}/{option} t={t} ({x},{y}): want {want:?}, got {got:?}")); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); + } + + #[test] + fn every_transition_starts_old_and_ends_new() { + for (kind, _, _, _, opts) in deckcraft_model::anim::TRANSITIONS { + let mut options: Vec<&str> = opts.to_vec(); + options.push(""); + for o in options { + assert_eq!(at(&frame(kind, o, 0.0), 0.5, 0.5), Is::Old, "{kind}/{o}"); + assert_eq!(at(&frame(kind, o, 1.0), 0.5, 0.5), Is::New, "{kind}/{o}"); + for t in [0.25, 0.5, 0.75] { + let img = frame(kind, o, t); + assert_eq!((img.width, img.height), (W, H)); + } + } + } + } + + #[test] + fn dynamic_content_moves_contents_over_backgrounds() { + // Old: red background with a white content square in the middle; new: blue background + // with a yellow square. + let content = |c: [u8; 4]| { + let mut img = solid(W, H, [0, 0, 0, 0]); + for y in 35..55u32 { + for x in 70..90u32 { + let i = ((y * W + x) * 4) as usize; + if let Some(p) = img.pixels.get_mut(i..i + 4) { + p.copy_from_slice(&c); + } + } + } + img + }; + let parts = SlideParts { + old_back: Some(solid(W, H, OLD)), + old_content: Some(content([255, 255, 255, 255])), + new_back: solid(W, H, NEW), + new_content: content([255, 220, 0, 255]), + }; + let old = solid(W, H, OLD); + let new = solid(W, H, NEW); + let f = |kind: &str, o: &str, t: f64| composite_transition(Some(&old), &new, Some(&parts), &transition_frame(kind, o, t, 16.0 / 9.0)); + // Pan left at the middle: the backgrounds blend, the old square has moved left. + let img = f("pan", "l", 0.5); + let bg = img.pixel(5, 5); + assert!(bg[0] > 60 && bg[2] > 60, "{bg:?}"); + assert_eq!(img.pixel(80, 45), bg, "the middle shows background only"); + // Pan right: the new square arrives from the left. + let img = f("pan", "r", 0.6); + assert!((0..80).any(|x| { + let p = img.pixel(x, 45); + p[0] > 200 && p[1] > 180 && p[2] < 80 + })); + // Reveal: the new content is in place near the end. + let p = f("reveal", "smoothlyLeft", 0.97).pixel(80, 45); + assert!(p[0] > 200 && p[1] > 180, "{p:?}"); + // Without parts the dynamic transitions still draw whole slides. + let img = composite_transition(Some(&old), &new, None, &transition_frame("pan", "l", 0.5, 16.0 / 9.0)); + assert_ne!(img.pixel(80, 45), [0, 0, 0, 255]); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 8e84792..428eff0 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1382,7 +1382,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); egui::Popup::menu(&r).show(|ui| { for o in opts { - if ui.button(option_label(o)).clicked() { + if ui.button(deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { run(app, "transition.options", json!({"option": o})); } } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1c8e6e1..17ec527 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -61,6 +61,10 @@ struct Transition { /// The slide left behind (Morph matches its shapes) and the Morph frame texture. from: Option, morph: Option, + /// Backgrounds and contents of the old and new slides, rendered once when the transition + /// moves contents over backgrounds (Pan, Ferris Wheel, Reveal…): old back, old content, new + /// back, new content. + parts: Option<[Option; 4]>, start: f64, kind: String, option: String, @@ -138,7 +142,7 @@ impl Show { self.last_viewed = from; } self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { - Some(Transition { old, from, morph: None, start: now, kind, option, dur }) + Some(Transition { old, from, morph: None, parts: None, start: now, kind, option, dur }) } else { None }; @@ -202,24 +206,43 @@ fn fit(avail: Rect, doc: &Presentation) -> Rect { Rect::from_center_size(avail.center(), vec2(sw * s, sh * s)) } +/// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective +/// cards subdivided so their pictures stay straight) with per-vertex opacity. fn quad_mesh(tex: Option, rect: Rect, l: &Layer, color: Color32) -> Mesh { let mut m = match tex { Some(id) => Mesh::with_texture(id), None => Mesh::default(), }; - let a = (l.alpha.clamp(0.0, 1.0) * 255.0) as u8; - let col = if tex.is_some() { Color32::from_white_alpha(a) } else { color.gamma_multiply(l.alpha.clamp(0.0, 1.0) as f32) }; - for k in 0..4 { - let q = l.quad.get(k).copied().unwrap_or([0.0, 0.0]); - let uv = l.uv.get(k).copied().unwrap_or([0.0, 0.0]); - let p = pos2(rect.min.x + q[0] as f32 * rect.width(), rect.min.y + q[1] as f32 * rect.height()); - m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(uv[0] as f32, uv[1] as f32), color: col }); + let (verts, tris) = deckcraft_anim::layer_mesh(l); + for v in &verts { + let a = if v.a.is_finite() { v.a.clamp(0.0, 1.0) } else { 0.0 }; + let col = if tex.is_some() { Color32::from_white_alpha((a * 255.0).round() as u8) } else { color.gamma_multiply(a as f32) }; + let p = pos2(rect.min.x + v.pos[0] as f32 * rect.width(), rect.min.y + v.pos[1] as f32 * rect.height()); + m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(v.uv[0] as f32, v.uv[1] as f32), color: col }); + } + for t in tris { + m.add_triangle(t[0], t[1], t[2]); } - m.add_triangle(0, 1, 2); - m.add_triangle(0, 2, 3); m } +/// Render the backgrounds and contents of a transition's two slides into textures, once. +fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, to: usize, size: (u32, u32)) -> [Option; 4] { + use deckcraft_render::SlidePart; + let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; + let tex = |i: usize, part: SlidePart, name: &str| { + let img = deckcraft_render::render_slide_part(doc, i, part, &opts); + ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR) + }; + [ + from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")), + from.map(|i| tex(i, SlidePart::Content, "trans-old-content")), + Some(tex(to, SlidePart::Backdrop, "trans-new-back")), + Some(tex(to, SlidePart::Content, "trans-new-content")), + ] +} + pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { @@ -470,18 +493,37 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { tr.morph = Some(frame); drew = true; ctx.request_repaint(); - } else if let (Some(tr), Some(new)) = (&show.trans, &tex) { + } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) { let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); if p < 1.0 { - for l in deckcraft_anim::transition_layers(&tr.kind, &tr.option, p) { + // Slides split into background and content are rendered once per transition; + // every frame only composites cached textures. + if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { + tr.parts = Some(part_textures(&ctx, &doc, tr.from, idx, size)); + } + let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); + let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); + let parts = [part(0), part(1), part(2), part(3)]; + for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { + let old_or_black = |t: Option<&TextureHandle>| match t { + Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), + None => quad_mesh(None, srect, &l, Color32::BLACK), + }; let mesh = match l.source { - Source::Old => match &tr.old { - Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), - None => quad_mesh(None, srect, &l, Color32::BLACK), - }, + Source::Old => old_or_black(tr.old.as_ref()), Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), Source::White => quad_mesh(None, srect, &l, Color32::WHITE), + Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())), + Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE), + Source::OldContent => match &parts[1] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, + Source::NewContent => match &parts[3] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, }; painter.add(egui::Shape::mesh(mesh)); } diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 1d50766..8ec5d0f 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; effect lists counted from `crates/model/src/anim.rs` against PowerPoint 16.113's galleries) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created) · **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) @@ -9,14 +9,31 @@ notice first when it's missing. Scores roll up into [target-app-parity.md](targe ## Transitions: 48 of 48 `crates/model/src/anim.rs` `TRANSITIONS` lists every transition in PowerPoint's gallery, in its -three groups, with its Effect Options. All 48 round-trip through PPTX -(`every_transition_kind_round_trips`), including the PowerPoint 2010+ `p14`/`p15` ones. +three groups, with its Effect Options (now also Page Curl Single, Glitter Hexagons, Shred In/Out +and Zoom and Rotate). All 48 round-trip through PPTX with the elements, attributes and defaults +PowerPoint itself writes (`every_transition_kind_round_trips`, +`transition_xml_matches_powerpoints_elements`, `transition_defaults_read_like_powerpoint`): 145 of +145 PowerPoint-built test slides reopen in PowerPoint with the same effect after a DeckCraft +save. + +**Measured against PowerPoint (2026-10-13).** Synthetic two-colour decks with asymmetric markers +were built in PowerPoint for Windows over COM with every transition and option (150 variants, +2 s each), exported with `CreateVideo` at 30 fps, and compared frame by frame with +`deckcraft-cli render-transition` at the same progress (`plan/powerpoint/tools/synth_transitions.ps1`, +`trans_compare.py`). Mean absolute difference over all variants at t = 0.1…0.9 (0–255): **35.7 +before, 15.0 after**. Curves (fades are a smoothstep, moves ease in-out; Cover, Uncover and Pan +have their own measured curves), directions and option meanings (e.g. Glitter starts on the +option's side, Ripple's corner is where the wave heads, Wind/Airplane/Origami "Left" is `invX`), +soft edges (Wipe, Split, Shape, Clock, Ripple) and the Dynamic Content transitions moving the +slides' contents over cross-fading backgrounds now follow PowerPoint. 3-D transitions are +perspective-projected cards (`crates/anim/src/transition_geom.rs`) drawn with +perspective-correct subdivided meshes. | Group | PowerPoint | DeckCraft | Fidelity | |---|---|---|---| -| Subtle (Morph, Fade, Push, Wipe, Split, Reveal, Cut, Random Bars, Shape, Uncover, Cover, Flash) | 12 | 12 | 2-D, close to PowerPoint | -| Exciting (Fall Over, Drape, Curtains, Wind, Prestige, Fracture, Crush, Peel Off, Page Curl, Airplane, Origami, Dissolve, Checkerboard, Blinds, Clock, Ripple, Honeycomb, Glitter, Vortex, Shred, Switch, Flip, Gallery, Cube, Doors, Box, Comb, Zoom, Random) | 29 | 29 | 3-D ones are fake 3-D (trapezoid quads, `crates/anim/src/transition.rs`); particle effects (Vortex, Shred, Fracture, Crush) approximate | -| Dynamic Content (Pan, Ferris Wheel, Conveyor, Rotate, Window, Orbit, Fly Through) | 7 | 7 | Same approach | +| Subtle (Morph, Fade, Push, Wipe, Split, Reveal, Cut, Random Bars, Shape, Uncover, Cover, Flash) | 12 | 12 | Matches PowerPoint's frames (mean diff 1–8; Random Bars and Reveal Through Black ~15) | +| Exciting (Fall Over, Drape, Curtains, Wind, Prestige, Fracture, Crush, Peel Off, Page Curl, Airplane, Origami, Dissolve, Checkerboard, Blinds, Clock, Ripple, Honeycomb, Glitter, Vortex, Shred, Switch, Flip, Gallery, Cube, Doors, Box, Comb, Zoom, Random) | 29 | 29 | Perspective 3-D cards with measured motion; cloth, paper and particle effects (Curtains, Peel Off, Page Curl, Airplane, Origami, Fracture, Crush, Honeycomb, Vortex, Shred) are flat approximations of PowerPoint's meshes | +| Dynamic Content (Pan, Ferris Wheel, Conveyor, Rotate, Window, Orbit, Fly Through) | 7 | 7 | Contents move over cross-fading backgrounds, as in PowerPoint (mean diff 5–17) | | Transition feature | PowerPoint | DeckCraft | |---|---|---| @@ -28,7 +45,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX | Morph: Words and Characters | yes | yes (#5) | | Morph: geometry (circle to star) | yes | yes (#27) | | Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink | -| Frame-by-frame comparison with PowerPoint | | **not done** | +| Frame-by-frame comparison with PowerPoint | | done (Windows, 2026-10-13); headless frames: `deckcraft-cli render-transition` | ## Animations: 64 of ~190 effects @@ -69,11 +86,12 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Rewind and other effect options on round trip (#77) | 1–2 | | Transition sounds | 1–2 | | Morph across pictures, `!!` names, align once per transition (#67) | 2–4 | -| Reference captures from PowerPoint for the 3-D transitions, then tuning | 2–4 | -| **Total** | **~14–26** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) | +| Cloth, paper and particle transitions as real meshes (Curtains, Peel Off, Page Curl, Airplane, Origami, Vortex, Shred, Honeycomb) | 4–8 | +| **Total** | **~16–30** (the Transitions and Animations rows of the feature table; part of the Features dimension's 140–240 h) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | diff --git a/docs/architecture.md b/docs/architecture.md index 58b8c8b..2849f87 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code on main at d556ff9; AGENTS.md §4 keeps the rules) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -49,8 +49,12 @@ Programmatic calls never open dialogs. Pointer gestures go through `Session::poi ## Rendering and the show The renderer draws a slide to an RGBA pixmap on the CPU; the UI uploads it as an egui texture. -Transitions are layers of textured quads over the old and new slide images (fake 3-D from -trapezoids). The show window and the presenter view are egui viewports; animation state per shape +Transitions are layers of textured quads over the old and new slide images, or over their +backgrounds and contents rendered apart (Dynamic Content), from `anim::transition_frame`: 3-D +effects project cards through a perspective camera and `anim::layer_mesh` subdivides them so +textures stay straight; soft edges are per-corner opacities. The show renders the slides once per +transition and only composites cached textures each frame; `render::composite_transition` is the +CPU twin used by `deckcraft-cli render-transition` and the tests. The show window and the presenter view are egui viewports; animation state per shape comes from `anim::timeline` each frame. PDF export renders each slide at print resolution and overlays an invisible real-text layer with embedded font subsets. @@ -74,4 +78,5 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |