mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #137 from alexanderalber: Morph: a picture cropped differently on the next slide morphs its crop (#118)
# Conflicts: # crates/anim/src/lib.rs # crates/anim/src/tests.rs
This commit is contained in:
@@ -38,7 +38,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub use easing::ease;
|
||||
pub use interact::{Click, HitShape, Target};
|
||||
pub use mesh::{MeshVertex, layer_mesh};
|
||||
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, StepStamp, Timeline, TriggerPlay};
|
||||
|
||||
@@ -176,9 +176,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
|
||||
@@ -226,8 +275,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
|
||||
{
|
||||
|
||||
@@ -549,6 +549,41 @@ fn morph_pairs_leaves_unrelated_text_boxes_alone() {
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]);
|
||||
}
|
||||
|
||||
#[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) };
|
||||
|
||||
@@ -42,7 +42,7 @@ perspective-correct subdivided meshes.
|
||||
| Duration, advance on click / after time, Apply To All | yes | yes |
|
||||
| Transition sounds | yes | yes: Transitions › Sound offers six built-in sounds (synthesised, no sound files shipped), Other Sound…, [Stop Previous Sound] and Loop Until Next Sound; they play in the show and round-trip |
|
||||
| Preview in the editor | yes | yes |
|
||||
| Morph: objects move, resize, recolour; unmatched text boxes fade in place | yes | yes (#119) |
|
||||
| Morph: objects move, resize, recolour; unmatched text boxes fade in place; a picture's crop morphs | yes | yes (#119, #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 |
|
||||
@@ -100,4 +100,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| 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-13 | minor | Triggers now play in the slide show (they were read and written but never started), effects of different triggers apply in the order they were clicked |
|
||||
| 2026-10-11 | minor | Morph leaves unrelated text boxes unpaired: they fade in place (#119) |
|
||||
| 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