mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Morph: a picture cropped differently on the next slide morphs its crop (#118)
Two paired pictures that differed only in crop counted as changed content, so the new one faded in over the old one on the moving box. Now they stay one picture whose crop morphs: the whole image moves and scales linearly between where it lies on either slide, and each frame's crop is what the interpolated box shows of it (morph_crop). generated with Claude Code
This commit is contained in:
@@ -27,7 +27,7 @@ pub use deckcraft_model::Rgba;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use easing::ease;
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, 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};
|
||||
|
||||
@@ -173,9 +173,58 @@ fn reshaped(a: &Shape, b: &Shape) -> bool {
|
||||
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
|
||||
}
|
||||
|
||||
/// The content of a shape apart from what Morph interpolates, with a picture's crop left out.
|
||||
fn uncropped(s: &Shape) -> Shape {
|
||||
let mut c = content(s);
|
||||
if let ShapeKind::Picture { fill } = &mut c.kind {
|
||||
fill.crop = [0.0; 4];
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn crop_of(s: &Shape) -> [f64; 4] {
|
||||
match &s.kind {
|
||||
ShapeKind::Picture { fill } => fill.crop,
|
||||
_ => [0.0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two pictures differ in their crop only (apart from box and fill), so the crop can morph.
|
||||
fn recropped(a: &Shape, b: &Shape) -> bool {
|
||||
matches!(a.kind, ShapeKind::Picture { .. }) && crop_of(a) != crop_of(b) && uncropped(a) == uncropped(b)
|
||||
}
|
||||
|
||||
/// The crop (left, top, right, bottom) of a picture morphing from box `xa` cropped by `ca` to box
|
||||
/// `xb` cropped by `cb`, when its box is `x` at progress `t`: the whole image moves and scales
|
||||
/// linearly between where it lies on either slide, and the crop is what of it `x` shows. Falls back
|
||||
/// to blending the crops when a crop leaves nothing visible.
|
||||
pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f64) -> [f64; 4] {
|
||||
// Along one axis: (box start, box size, crop before, crop after) → the crops at `t`.
|
||||
let axis = |(pa, sa, la, ha): (f64, f64, f64, f64), (pb, sb, lb, hb): (f64, f64, f64, f64), (p, s): (f64, f64)| {
|
||||
let (va, vb) = (1.0 - la - ha, 1.0 - lb - hb);
|
||||
if va <= 1e-6 || vb <= 1e-6 {
|
||||
return (lerp(la, lb, t), lerp(ha, hb, t));
|
||||
}
|
||||
// The whole image: its extent and start on either slide.
|
||||
let (ea, eb) = (sa / va, sb / vb);
|
||||
let e = lerp(ea, eb, t);
|
||||
if !e.is_finite() || e.abs() <= 1e-9 {
|
||||
return (lerp(la, lb, t), lerp(ha, hb, t));
|
||||
}
|
||||
let o = lerp(pa - la * ea, pb - lb * eb, t);
|
||||
let lo = (p - o) / e;
|
||||
(lo, 1.0 - lo - s / e)
|
||||
};
|
||||
let (l, r) = axis((xa.x, xa.w, ca[0], ca[2]), (xb.x, xb.w, cb[0], cb[2]), (x.x, x.w));
|
||||
let (tp, bt) = axis((xa.y, xa.h, ca[1], ca[3]), (xb.y, xb.h, cb[1], cb[3]), (x.y, x.h));
|
||||
let c = [l, tp, r, bt];
|
||||
if c.iter().all(|v| v.is_finite()) { c } else { cb }
|
||||
}
|
||||
|
||||
/// 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`]),
|
||||
/// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), 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
|
||||
@@ -223,8 +272,18 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
(_, xb) => xb,
|
||||
};
|
||||
let reshape = reshaped(sa, sb);
|
||||
if reshape || content(sa) == content(sb) {
|
||||
let recrop = recropped(sa, sb);
|
||||
if reshape || recrop || content(sa) == content(sb) {
|
||||
let mut m = Shape { xfrm: x, ..sb.clone() };
|
||||
if recrop && let ShapeKind::Picture { fill } = &mut m.kind {
|
||||
fill.crop = match (xa, xb, x) {
|
||||
(Some(xa), Some(xb), Some(x)) => morph_crop(crop_of(sa), xa, crop_of(sb), xb, x, s),
|
||||
_ => {
|
||||
let (ca, cb) = (crop_of(sa), crop_of(sb));
|
||||
[lerp(ca[0], cb[0], s), lerp(ca[1], cb[1], s), lerp(ca[2], cb[2], s), lerp(ca[3], cb[3], s)]
|
||||
}
|
||||
};
|
||||
}
|
||||
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
|
||||
&& fa != fb
|
||||
{
|
||||
|
||||
@@ -529,6 +529,41 @@ fn morph_pairs_by_name_text_and_geometry() {
|
||||
assert!(morph_pairs(&Slide::default(), &b).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_crop_moves_the_image_linearly() {
|
||||
let bx = |x, w| Xfrm::new(x, 0.0, w, 100.0);
|
||||
// Same box, the crop pans from the left half to the right half of a 200-wide image.
|
||||
let c = morph_crop([0.0, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.5, 0.0, 0.0, 0.0], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
|
||||
assert!(c.iter().zip([0.25, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
|
||||
// The box shrinks over an image that stays put: only the right crop grows.
|
||||
let c = morph_crop([0.0; 4], bx(0.0, 100.0), [0.0, 0.0, 0.5, 0.0], bx(0.0, 50.0), bx(0.0, 75.0), 0.5);
|
||||
assert!(c.iter().zip([0.0, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
|
||||
// Nothing visible on one side: the crops blend.
|
||||
let c = morph_crop([0.5, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.0; 4], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
|
||||
assert!(c.iter().all(|v| v.is_finite()) && (c[0] - 0.25).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_morphs_a_picture_crop() {
|
||||
// A picture recropped on the next slide stays one picture whose crop morphs (#118).
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let pic = |id, crop| Shape {
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, ..Default::default() } },
|
||||
..shape(id, "Picture 1")
|
||||
};
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![pic(1, [0.0, 0.0, 0.5, 0.0])];
|
||||
b.shapes = vec![pic(2, [0.5, 0.0, 0.0, 0.0])];
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
assert_eq!(f.shapes.len(), 1);
|
||||
assert!(f.opacity.is_empty());
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &f.shapes[0].0.kind else { panic!("not a picture") };
|
||||
let want = 0.5 * f.mix;
|
||||
assert!((fill.crop[0] - want).abs() < 1e-9 && (fill.crop[2] - (0.5 - want)).abs() < 1e-9, "{:?}", fill.crop);
|
||||
assert_eq!(morph_frame(&p, &a, &b, 1.0, &|_| false).shapes[0].0.kind, b.shapes[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_xfrm_interpolates() {
|
||||
let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) };
|
||||
|
||||
@@ -24,7 +24,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX
|
||||
| Duration, advance on click / after time, Apply To All | yes | yes |
|
||||
| Transition sounds | yes | **no** (the model has the field; no picker, no playback) |
|
||||
| Preview in the editor | yes | yes |
|
||||
| Morph: objects move, resize, recolour | yes | yes |
|
||||
| Morph: objects move, resize, recolour; a picture's crop morphs | yes | yes (#118) |
|
||||
| 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 |
|
||||
@@ -76,4 +76,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Morph animates a changed picture crop (#118) |
|
||||
| 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates |
|
||||
|
||||
Reference in New Issue
Block a user