deckcraft/crates/anim/src/morph.rs

253 lines
9.8 KiB
Rust

//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
use crate::clampf;
use crate::easing::smooth;
fn text_of(s: &Shape) -> String {
s.text.as_ref().map(|t| t.text()).unwrap_or_default()
}
/// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by
/// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same
/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole).
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
let mut used_a = vec![false; a.shapes.len()];
let mut used_b = vec![false; b.shapes.len()];
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
&|x, y| x.name.starts_with("!!") && x.name == y.name,
&|x, y| !x.name.is_empty() && x.name == y.name,
&|x, y| {
if x.kind_name() != y.kind_name() {
return false;
}
let tx = text_of(x);
!tx.trim().is_empty() && tx == text_of(y)
},
&|x, y| {
x.kind_name() == y.kind_name()
&& x.geom.preset_name().is_some()
&& x.geom.preset_name() == y.geom.preset_name()
&& text_of(x).trim().is_empty() == text_of(y).trim().is_empty()
},
];
for pass in passes {
for (i, sa) in a.shapes.iter().enumerate() {
if used_a.get(i).copied().unwrap_or(true) {
continue;
}
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
if let Some((j, sb)) = found {
if let Some(u) = used_a.get_mut(i) {
*u = true;
}
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
}
}
}
pairs
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
let v = a + (b - a) * t;
if v.is_finite() {
v
} else if t < 0.5 {
a
} else {
b
}
}
/// Interpolate a box from `a` to `b` at `t` (0..1): position and size linearly, rotation along the
/// shortest way round; flips switch at the midpoint.
pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm {
let t = clampf(t, 0.0, 1.0, 0.0);
if t >= 1.0 {
return b;
}
if t <= 0.0 {
return a;
}
let ra = if a.rot.is_finite() { a.rot } else { 0.0 };
let rb = if b.rot.is_finite() { b.rot } else { 0.0 };
let mut d = (rb - ra).rem_euclid(360.0);
if d > 180.0 {
d -= 360.0;
}
let rot = (ra + d * t).rem_euclid(360.0);
let late = t >= 0.5;
Xfrm {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
w: lerp(a.w, b.w, t),
h: lerp(a.h, b.h, t),
rot: if rot.is_finite() { rot } else { 0.0 },
flip_h: if late { b.flip_h } else { a.flip_h },
flip_v: if late { b.flip_v } else { a.flip_v },
}
}
/// One frame of a Morph transition, ready for the renderer's `render_blend`: the backdrops of the
/// two slides cross-fade by [`MorphFrame::mix`], the shapes are drawn in order on top.
#[derive(Clone, Debug, Default)]
pub struct MorphFrame {
/// Shapes with fresh ids, each flagged `true` when it comes from the old slide (it resolves
/// its placeholder, theme and style against that slide).
pub shapes: Vec<(Shape, bool)>,
/// Opacity of the frame's shapes by id; shapes not listed are opaque.
pub opacity: Vec<(ShapeId, f64)>,
pub mix: f64,
/// With the Words or Characters option: pairs of frame shapes whose texts morph by words or
/// characters instead of cross-fading (the renderer's `TextMorph`).
pub text: Vec<MorphText>,
/// Frame shapes whose outline turns from another geometry into their own (the renderer's
/// `PathMorph`).
pub paths: Vec<MorphPath>,
}
/// A frame shape whose outline morphs from `from` (the old shape's geometry) into its own.
#[derive(Clone, Debug, PartialEq)]
pub struct MorphPath {
pub id: ShapeId,
pub from: Geom,
/// The boxes of the old and the new shape on their own slides.
pub from_box: Xfrm,
pub to_box: Xfrm,
}
/// Two frame shapes whose texts morph by words or characters.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MorphText {
/// Frame shape ids: from the old slide, from the new slide.
pub old: ShapeId,
pub new: ShapeId,
/// The boxes of the two shapes on their own slides.
pub from: Xfrm,
pub to: Xfrm,
pub chars: bool,
}
impl MorphFrame {
/// Opacity of shape `id` of the frame.
pub fn opacity_of(&self, id: ShapeId) -> f64 {
self.opacity.iter().find(|(s, _)| *s == id).map(|(_, o)| *o).unwrap_or(1.0)
}
}
fn mix_color(a: Rgba, b: Rgba, t: f64) -> Rgba {
let m = |x: u8, y: u8| clampf(lerp(x as f64, y as f64, t), 0.0, 255.0, 0.0).round() as u8;
Rgba { r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b), a: m(a.a, b.a) }
}
fn solid_color(ctx: Option<&Ctx>, s: &Shape) -> Option<Rgba> {
let ctx = ctx?;
match resolve::fill(ctx, s) {
(Some(Fill::Solid { color }), ph) => Some(ctx.color(&color, ph)),
_ => None,
}
}
fn box_of(ctx: Option<&Ctx>, s: &Shape) -> Option<Xfrm> {
match ctx {
Some(c) => Some(resolve::xfrm(c, s)),
None => s.xfrm,
}
}
/// The content of a shape apart from what Morph interpolates (identity, box and fill).
fn content(s: &Shape) -> Shape {
Shape { id: ShapeId(0), name: String::new(), xfrm: None, fill: None, ..s.clone() }
}
/// Whether two shapes differ in their geometry only, and it is drawn as an outline (shapes,
/// pictures, connectors), so the outline can morph.
fn reshaped(a: &Shape, b: &Shape) -> bool {
let outlined = |s: &Shape| matches!(s.kind, ShapeKind::Shape | ShapeKind::Picture { .. } | ShapeKind::Connector { .. });
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
}
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
/// otherwise the new shape fades in under the fading old one on the same moving
/// box; with the transition option `words` or `characters` their texts are listed in
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
/// which `hidden_b` is true (entrance animations, hidden media) stay out of the frame.
pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: &dyn Fn(ShapeId) -> bool) -> MorphFrame {
let s = smooth(clampf(t, 0.0, 1.0, 0.0));
let ca = Ctx::for_slide(pres, a);
let cb = Ctx::for_slide(pres, b);
let pairs: Vec<(ShapeId, ShapeId)> = morph_pairs(a, b).into_iter().filter(|(_, ib)| !hidden_b(*ib)).collect();
let mut next = 1u32;
walk(&a.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
walk(&b.shapes, &mut |x, _| next = next.max(x.id.0.saturating_add(1)));
let mut f = MorphFrame { mix: s, ..Default::default() };
let by = b.transition.as_ref().map(|tr| tr.option.as_str()).unwrap_or("");
let (by_text, chars) = (by == "words" || by == "characters", by == "characters");
let has_text = |x: &Shape| x.text.as_ref().is_some_and(|t| !t.is_empty()) && !matches!(x.kind, ShapeKind::Group { .. } | ShapeKind::Table(_));
let mut text = Vec::new();
let mut paths = Vec::new();
let mut push = |mut sh: Shape, old: bool, op: f64| {
let id = ShapeId(next);
sh.id = id;
next = next.saturating_add(1);
if op < 1.0 {
f.opacity.push((id, op));
}
f.shapes.push((sh, old));
id
};
for sa in &a.shapes {
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
push(sa.clone(), true, 1.0 - s);
}
}
for sb in &b.shapes {
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
let Some(sa) = pair else {
if !hidden_b(sb.id) {
push(sb.clone(), false, s);
}
continue;
};
let (xa, xb) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb));
let x = match (xa, xb) {
(Some(xa), Some(xb)) => Some(morph_xfrm(xa, xb, s)),
(_, xb) => xb,
};
let reshape = reshaped(sa, sb);
if reshape || content(sa) == content(sb) {
let mut m = Shape { xfrm: x, ..sb.clone() };
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
&& fa != fb
{
m.fill = Some(Fill::solid(ColorRef::rgb(mix_color(fa, fb, s))));
}
let id = push(m, false, 1.0);
if reshape && let (Some(from_box), Some(to_box)) = (xa, xb) {
paths.push(MorphPath { id, from: sa.geom.clone(), from_box, to_box });
}
} else {
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
if by_text
&& has_text(sa)
&& has_text(sb)
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
{
text.push(MorphText { old, new, from, to, chars });
}
}
}
f.text = text;
f.paths = paths;
f
}