From 788831834bd45e28b88344415aee683d800e7069 Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:19:32 +0200 Subject: [PATCH] Morph: a changed geometry morphs its outline (a circle turns into a star) (#27) * Morph: a changed geometry morphs its outline (a circle turns into a star) Paired shapes that differ only in geometry (another preset, other adjust values, a custom path) and fill colour no longer cross-fade: one shape moves and its outline turns from the old geometry into the new one. - geom::morph aligns two outlines the way manim does: the same number of contours, the same number of cubic curves per contour pair, then every point moves in a straight line. Closed contours are turned and reversed so the points travel least (a triangle does not twist into a circle), extra points go to the longest curves, and a sub-path without partner grows out of (or shrinks into) a point, so a cube's faces appear and a donut's hole closes. Fill mode, outline and fill rule switch at the midpoint. - anim::morph_frame lists such shapes in MorphFrame::paths; the renderer draws them with the morphed geometry (PathMorph, a new render_blend argument next to TextMorph). Ported from the path morph in alexplain (MIT, same author). generated with Claude Code * Morph: differing looks of an outline cross-fade instead of switching When a sub-path pair differs in fill mode, outline or fill rule, the two looks no longer switch at the midpoint. A fill or outline only one side has fades out (or in), so a filled shape turning into a line loses its fill gradually. When both fill, the even-odd side lies opaque underneath and the other fades over it, so a donut's hole fills smoothly while it shrinks. generated with Claude Code --------- Co-authored-by: Brandon Thomas --- crates/anim/src/lib.rs | 2 +- crates/anim/src/morph.rs | 38 ++- crates/anim/src/tests.rs | 37 +++ crates/geom/src/lib.rs | 1 + crates/geom/src/morph.rs | 555 ++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 52 ++- crates/render/src/morph_path.rs | 40 +++ crates/render/src/tests.rs | 76 ++++- crates/ui-egui/src/show.rs | 7 +- 9 files changed, 781 insertions(+), 27 deletions(-) create mode 100644 crates/geom/src/morph.rs create mode 100644 crates/render/src/morph_path.rs diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index cf5627b..5d9f6cf 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -27,7 +27,7 @@ pub use deckcraft_model::Rgba; use serde::{Deserialize, Serialize}; pub use easing::ease; -pub use morph::{MorphFrame, MorphText, morph_frame, morph_pairs, morph_xfrm}; +pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; pub use show::{ShowAction, ShowState, show_order}; pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index 7fd1e05..b5808c4 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -1,7 +1,7 @@ //! Morph: matching shapes between two slides, interpolating their boxes and building the frames. use deckcraft_model::resolve::{self, Ctx}; -use deckcraft_model::{ColorRef, Fill, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk}; +use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk}; use crate::clampf; use crate::easing::smooth; @@ -107,6 +107,19 @@ pub struct MorphFrame { /// 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, + /// Frame shapes whose outline turns from another geometry into their own (the renderer's + /// `PathMorph`). + pub paths: Vec, +} + +/// 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. @@ -153,9 +166,17 @@ 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, otherwise the new shape fades in under the fading old one on the same moving +/// 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. @@ -172,6 +193,7 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: 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; @@ -195,18 +217,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: } continue; }; - let x = match (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb)) { + 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, }; - if content(sa) == content(sb) { + 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)))); } - push(m, false, 1.0); + 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); @@ -220,5 +247,6 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: } } f.text = text; + f.paths = paths; f } diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index da35625..a360a25 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -617,6 +617,43 @@ fn morph_frame_lists_texts_for_words_and_characters() { assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty()); } +#[test] +fn morph_frame_morphs_the_outline_of_a_changed_geometry() { + let p = deckcraft_model::defaults::new_presentation(None); + let mut a = Slide::default(); + let mut b = Slide::default(); + let geo = |s: Shape, g: &str| Shape { geom: deckcraft_model::Geom::preset(g), ..s }; + a.shapes = vec![solid(geo(shape(1, "Form"), "ellipse"), 0)]; + b.shapes = vec![solid(Shape { xfrm: Some(Xfrm::new(300.0, 100.0, 100.0, 100.0)), ..geo(shape(1, "Form"), "star5") }, 200)]; + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + // One shape: the new one, on the moving box, its colour blending and its outline listed to morph. + assert_eq!(f.shapes.len(), 1); + let (m, old) = &f.shapes[0]; + assert!(!old && m.geom == b.shapes[0].geom); + assert_eq!(f.opacity_of(m.id), 1.0); + assert_eq!(m.fill, Some(deckcraft_model::Fill::solid(ColorRef::rgb(Rgba { r: 100, g: 0, b: 0, a: 255 })))); + assert_eq!(f.paths.len(), 1); + assert_eq!(f.paths[0].id, m.id); + assert_eq!(f.paths[0].from, a.shapes[0].geom); + assert_eq!((f.paths[0].from_box, f.paths[0].to_box), (a.shapes[0].xfrm.unwrap(), b.shapes[0].xfrm.unwrap())); + // Other adjust values of the same preset morph too. + b.shapes[0].geom = deckcraft_model::Geom::Preset { name: "ellipse".into(), adj: vec![1.0] }; + assert_eq!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.len(), 1); + // The same geometry only moves. + b.shapes[0].geom = a.shapes[0].geom.clone(); + assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.is_empty()); + // Another text as well: the two cross-fade as before. + b.shapes[0].geom = deckcraft_model::Geom::preset("star5"); + b.shapes[0].text = Some(TextBody::from_text("Star")); + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + assert!(f.paths.is_empty() && f.shapes.len() == 2); + // Tables and groups have no outline to morph. + let table = |g: &str| Shape { kind: deckcraft_model::ShapeKind::Table(Default::default()), ..geo(shape(1, "Form"), g) }; + a.shapes = vec![table("rect")]; + b.shapes = vec![table("ellipse")]; + assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).paths.is_empty()); +} + fn pres(n: usize) -> Presentation { Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() } } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 7a245d8..038a6e5 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod morph; pub mod preset; pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2}; diff --git a/crates/geom/src/morph.rs b/crates/geom/src/morph.rs new file mode 100644 index 0000000..411da5f --- /dev/null +++ b/crates/geom/src/morph.rs @@ -0,0 +1,555 @@ +//! Morph between two outlines: one shape turns into another (a circle into a star). +//! +//! The approach is manim's: both outlines get the same number of contours and each contour pair the +//! same number of cubic curves, then every control point moves in a straight line. On top of that, +//! closed contours are turned and reversed so that their points travel as short a way as possible +//! (a triangle does not twist on its way into a circle), and extra points go to the longest curves. + +use kurbo::{BezPath, PathEl, Point, Rect, Vec2}; + +use crate::preset::{FillMode, Geometry, SubPath}; + +/// A contour as cubic curves: the start anchor, then three points per curve (handle, handle, +/// anchor). +#[derive(Clone, Debug, PartialEq)] +pub struct Contour { + pub closed: bool, + pub pts: Vec, +} + +impl Contour { + pub fn curves(&self) -> usize { + self.pts.len().saturating_sub(1) / 3 + } +} + +fn line_to(p: &mut Vec, to: Point) { + let Some(&from) = p.last() else { return }; + p.extend([from.lerp(to, 1.0 / 3.0), from.lerp(to, 2.0 / 3.0), to]); +} + +fn close(c: &mut Contour) { + if let (Some(&first), Some(&last)) = (c.pts.first(), c.pts.last()) + && first != last + { + line_to(&mut c.pts, first); + } + c.closed = true; +} + +fn pen(cur: &mut Option, start: Point) -> &mut Contour { + cur.get_or_insert_with(|| Contour { closed: false, pts: vec![start] }) +} + +fn keep(c: Contour, out: &mut Vec) { + if c.curves() > 0 && c.pts.iter().all(|p| p.is_finite()) { + out.push(c); + } +} + +/// The contours of a path, every segment as a cubic curve. Contours without a curve or with +/// non-finite points are left out. +pub fn contours(path: &BezPath) -> Vec { + let mut out = Vec::new(); + let mut cur: Option = None; + let mut start = Point::ZERO; + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + if let Some(c) = cur.take() { + keep(c, &mut out); + } + start = p; + cur = Some(Contour { closed: false, pts: vec![p] }); + } + PathEl::LineTo(p) => line_to(&mut pen(&mut cur, start).pts, p), + PathEl::QuadTo(q, p) => { + let c = pen(&mut cur, start); + if let Some(&a) = c.pts.last() { + c.pts.extend([a.lerp(q, 2.0 / 3.0), p.lerp(q, 2.0 / 3.0), p]); + } + } + PathEl::CurveTo(a, b, p) => pen(&mut cur, start).pts.extend([a, b, p]), + PathEl::ClosePath => { + if let Some(mut c) = cur.take() + && c.curves() > 0 + { + close(&mut c); + keep(c, &mut out); + } + } + } + } + if let Some(c) = cur { + keep(c, &mut out); + } + out +} + +/// Contours back to a path. +pub fn to_path(cs: &[Contour]) -> BezPath { + let mut bp = BezPath::new(); + for c in cs { + let Some(&s) = c.pts.first() else { continue }; + bp.move_to(s); + for [a, b, p] in c.pts.get(1..).unwrap_or(&[]).as_chunks::<3>().0 { + bp.curve_to(*a, *b, *p); + } + if c.closed { + bp.close_path(); + } + } + bp +} + +// --- alignment --------------------------------------------------------------------------------- + +/// Rough curve length: mean of chord and control polygon. +fn curve_length(c: &[Point]) -> f64 { + let [p0, p1, p2, p3] = c else { return 0.0 }; + (p0.distance(*p3) + p0.distance(*p1) + p1.distance(*p2) + p2.distance(*p3)) / 2.0 +} + +/// Appends curve `c` (four points) split into `n` pieces of equal parameter length (de Casteljau), +/// without its start anchor. +fn split_curve(c: &[Point], n: usize, out: &mut Vec) { + let [a, b, d, e] = c else { return }; + let (mut p0, mut p1, mut p2, p3) = (*a, *b, *d, *e); + for j in (2..=n).rev() { + let t = 1.0 / j as f64; + let (ab, bc, cd) = (p0.lerp(p1, t), p1.lerp(p2, t), p2.lerp(p3, t)); + let (abc, bcd) = (ab.lerp(bc, t), bc.lerp(cd, t)); + let m = abc.lerp(bcd, t); + out.extend([ab, abc, m]); + (p0, p1, p2) = (m, bcd, cd); + } + out.extend([p1, p2, p3]); +} + +/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra +/// splits go to the longest pieces, so a long side gets more points than a short one. +fn refine(c: &Contour, n: usize) -> Contour { + let have = c.curves(); + if have >= n || have == 0 { + return c.clone(); + } + let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect(); + let len: Vec = curves.iter().map(|q| curve_length(q) + 1e-9).collect(); + let mut parts = vec![1usize; curves.len()]; + for _ in 0..n - have { + let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64; + let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b }); + if let Some(p) = parts.get_mut(best) { + *p += 1; + } + } + let mut out = Vec::with_capacity(1 + 3 * n); + out.extend(c.pts.first().copied()); + for (q, p) in curves.iter().zip(&parts) { + split_curve(q, *p, &mut out); + } + Contour { closed: c.closed, pts: out } +} + +const MIN_CURVES: usize = 12; +const MAX_CURVES: usize = 60; +/// Start points tried per closed contour; longer contours are sampled evenly. +const MAX_TRIES: usize = 128; + +/// How many curves a contour pair gets: a common multiple of both counts while that stays small +/// (then a triangle meets a circle at matching places and the morph stays symmetric), and at least +/// [`MIN_CURVES`] for a smooth middle. +fn curves_for(na: usize, nb: usize) -> usize { + fn gcd(x: usize, y: usize) -> usize { + if y == 0 { x } else { gcd(y, x % y) } + } + let hi = na.max(nb).max(1); + let mut n = if na > 0 && nb > 0 { (na / gcd(na, nb)).saturating_mul(nb) } else { hi }; + if n > MAX_CURVES { + n = hi; + } + n.saturating_mul(MIN_CURVES.div_ceil(n).max(1)) +} + +/// Box around all points, handles included. +fn bounds<'a>(pts: impl IntoIterator) -> Option { + pts.into_iter().fold(None, |r: Option, p| Some(r.map_or(Rect::from_points(*p, *p), |r| r.union_pt(*p)))) +} + +fn center(pts: &[Point]) -> Point { + bounds(pts).map(|r| r.center()).unwrap_or(Point::ZERO) +} + +/// The same contour, starting at anchor `s`, optionally run backwards. +fn turn(pts: &[Point], s: usize, back: bool) -> Vec { + let q: Vec = if back { pts.iter().rev().copied().collect() } else { pts.to_vec() }; + if s == 0 { + return q; + } + let body = q.get(1..).unwrap_or(&[]); + let cut = (3 * s).min(body.len()); + let mut out = Vec::with_capacity(q.len()); + out.extend(cut.checked_sub(1).and_then(|i| body.get(i)).copied()); + out.extend_from_slice(body.get(cut..).unwrap_or(&[])); + out.extend_from_slice(body.get(..cut).unwrap_or(&[])); + out +} + +/// One contour pair of equal curve count: `b` runs backwards if `back` is set, then the closed side +/// (`b` if it is closed, else `a`) gets the start that lets the points travel least. Both sides are +/// compared around their own centre, so where the shapes are does not matter. +fn match_pair(a: &Contour, b: &Contour, back: bool) -> (Contour, Contour, f64) { + let bp = if back { turn(&b.pts, 0, true) } else { b.pts.clone() }; + let turn_a = !b.closed && a.closed; + let (fixed, free) = if turn_a { (&bp, &a.pts) } else { (&a.pts, &bp) }; + let (fc, gc) = (center(fixed), center(free)); + let tries = if turn_a || b.closed { free.len().saturating_sub(1) / 3 } else { 1 }; + let mut best = free.clone(); + let mut best_cost = f64::INFINITY; + for s in (0..tries.max(1)).step_by(tries.div_ceil(MAX_TRIES).max(1)) { + let q = turn(free, s, false); + let mut cost = 0.0; + for (p, f) in q.iter().zip(fixed.iter()) { + cost += ((*p - gc) - (*f - fc)).hypot2(); + if cost >= best_cost { + break; + } + } + if cost < best_cost { + best_cost = cost; + best = q; + } + } + let (pa, pb) = if turn_a { (best, bp) } else { (a.pts.clone(), best) }; + (Contour { closed: a.closed, pts: pa }, Contour { closed: b.closed, pts: pb }, best_cost) +} + +/// Where a contour without partner shrinks to (or grows from): the spot at the same place relative +/// to the other outline's box, or its own centre without one. +fn place(c: &Contour, from: Option, to: Option) -> Point { + let p = center(&c.pts); + let (Some(from), Some(to)) = (from, to) else { return p }; + let fx = if from.width() > 0.0 { (p.x - from.x0) / from.width() } else { 0.5 }; + let fy = if from.height() > 0.0 { (p.y - from.y0) / from.height() } else { 0.5 }; + Point::new(to.x0 + to.width() * fx, to.y0 + to.height() * fy) +} + +/// Two contour lists brought to the same number of contours and the same number of curves per +/// contour pair. Contours pair up in order; one without partner meets a point (see [`place`]; +/// `ba`/`bb` are the boxes of the whole outlines). Whether `b` runs backwards is decided once for all +/// its contours: turning single ones around would change which areas a nonzero fill covers. +pub fn align(a: &[Contour], b: &[Contour], ba: Option, bb: Option) -> (Vec, Vec) { + // (a, b, either side a point). + let mut pairs: Vec<(Contour, Contour, bool)> = Vec::new(); + for k in 0..a.len().max(b.len()) { + let (ca, cb) = (a.get(k), b.get(k)); + let n = curves_for(ca.map_or(0, Contour::curves), cb.map_or(0, Contour::curves)); + let point = |c: &Contour, from, to, closed| Contour { closed, pts: vec![place(c, from, to); 1 + 3 * n] }; + match (ca, cb) { + (Some(x), Some(y)) => pairs.push((refine(x, n), refine(y, n), false)), + (Some(x), None) => pairs.push((refine(x, n), point(x, ba, bb, x.closed), true)), + (None, Some(y)) => pairs.push((point(y, bb, ba, y.closed), refine(y, n), true)), + (None, None) => {} + } + } + let mut best: Option<(Vec, Vec, f64)> = None; + for back in [false, true] { + let (mut xs, mut ys, mut cost) = (Vec::new(), Vec::new(), 0.0); + for (x, y, point) in &pairs { + if *point { + xs.push(x.clone()); + ys.push(if back { Contour { closed: y.closed, pts: turn(&y.pts, 0, true) } } else { y.clone() }); + } else { + let (mx, my, c) = match_pair(x, y, back); + xs.push(mx); + ys.push(my); + cost += c; + } + } + if best.as_ref().is_none_or(|b| cost < b.2) { + best = Some((xs, ys, cost)); + } + } + best.map(|(x, y, _)| (x, y)).unwrap_or_default() +} + +// --- whole geometries ---------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Look { + fill: FillMode, + stroke: bool, + even_odd: bool, +} + +#[derive(Clone, Debug)] +struct Part { + a: Option, + b: Option, + ca: Vec, + cb: Vec, +} + +/// Two shape geometries brought to the same structure once, then blended per frame with +/// [`GeometryMorph::at`]. Sub-paths pair up in order; one without partner grows out of (or shrinks +/// into) a point. When a pair differs in fill mode, outline or fill rule, the two looks cross-fade +/// (see [`Fade`]). +#[derive(Clone, Debug)] +pub struct GeometryMorph { + parts: Vec, + text: (Rect, Rect), +} + +fn look(s: &SubPath) -> Look { + Look { fill: s.fill, stroke: s.stroke, even_odd: s.even_odd } +} + +/// How strongly a sub-path of a morphed geometry shows: its fill and its outline, 0..1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Fade { + pub fill: f64, + pub stroke: f64, +} + +/// The layers a pair of looks is drawn with at `t`, bottom first. A fill or an outline only one side +/// has fades out (or in). When both fill, the side with the even-odd rule lies opaque underneath and +/// the other fades over it, so a donut's hole fills smoothly; with the same rule both cover the same +/// area, so this is a plain cross-fade (shading of 3-D faces). +fn layers(a: Option, b: Option, t: f64) -> Vec<(Look, Fade)> { + let (a, b) = match (a, b) { + (Some(a), Some(b)) if a != b => (a, b), + (Some(x), _) | (None, Some(x)) => return vec![(x, Fade { fill: 1.0, stroke: 1.0 })], + (None, None) => return vec![], + }; + let side = |on_a: bool, on_b: bool| match (on_a, on_b) { + (true, true) => 1.0, + (true, false) => 1.0 - t, + (false, true) => t, + (false, false) => 0.0, + }; + let (stroke, line) = (a.stroke || b.stroke, side(a.stroke, b.stroke)); + let (fa, fb) = (a.fill != FillMode::None, b.fill != FillMode::None); + if fa && fb { + let (under, over, alpha) = if a.even_odd || !b.even_odd { (a, b, t) } else { (b, a, 1.0 - t) }; + vec![(Look { stroke, ..under }, Fade { fill: 1.0, stroke: line }), (Look { stroke: false, ..over }, Fade { fill: alpha, stroke: 0.0 })] + } else { + let filled = if fa { a } else { b }; + vec![(Look { stroke, ..filled }, Fade { fill: side(fa, fb), stroke: line })] + } +} + +impl GeometryMorph { + /// `a` and `b` each in their own shape-local space. + pub fn new(a: &Geometry, b: &Geometry) -> Self { + let ca: Vec> = a.paths.iter().map(|s| contours(&s.path)).collect(); + let cb: Vec> = b.paths.iter().map(|s| contours(&s.path)).collect(); + let ba = bounds(ca.iter().flatten().flat_map(|c| &c.pts)); + let bb = bounds(cb.iter().flatten().flat_map(|c| &c.pts)); + let mut parts = Vec::new(); + for k in 0..a.paths.len().max(b.paths.len()) { + let (xa, xb) = (ca.get(k).map(Vec::as_slice).unwrap_or(&[]), cb.get(k).map(Vec::as_slice).unwrap_or(&[])); + let (pa, pb) = align(xa, xb, ba, bb); + parts.push(Part { a: a.paths.get(k).map(look), b: b.paths.get(k).map(look), ca: pa, cb: pb }); + } + GeometryMorph { parts, text: (a.text_rect, b.text_rect) } + } + + /// The geometry at `t` (0..1), with a [`Fade`] for each of its sub-paths. The points of `a` are + /// first scaled by `sa` and those of `b` by `sb`, to bring both into the current box. + pub fn at(&self, t: f64, sa: Vec2, sb: Vec2) -> (Geometry, Vec) { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let scale = |p: Point, s: Vec2| Point::new(p.x * s.x, p.y * s.y); + let (mut paths, mut fades) = (Vec::new(), Vec::new()); + for part in &self.parts { + let cs: Vec = part + .ca + .iter() + .zip(&part.cb) + .map(|(x, y)| Contour { + closed: x.closed && y.closed, + pts: x.pts.iter().zip(&y.pts).map(|(p, q)| scale(*p, sa).lerp(scale(*q, sb), t)).collect(), + }) + .collect(); + let path = to_path(&cs); + for (lk, fade) in layers(part.a, part.b, t) { + paths.push(SubPath { path: path.clone(), fill: lk.fill, stroke: lk.stroke, even_odd: lk.even_odd }); + fades.push(fade); + } + } + let r = |r: Rect, s: Vec2| Rect::new(r.x0 * s.x, r.y0 * s.y, r.x1 * s.x, r.y1 * s.y); + let (ta, tb) = (r(self.text.0, sa), r(self.text.1, sb)); + let text_rect = Rect::from_points(ta.origin().lerp(tb.origin(), t), Point::new(ta.x1, ta.y1).lerp(Point::new(tb.x1, tb.y1), t)); + (Geometry { paths, text_rect, handles: vec![], sites: vec![] }, fades) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::preset::build; + use kurbo::Shape as _; + + fn bbox(g: &Geometry) -> Rect { + g.outline().bounding_box() + } + + fn near(a: Rect, b: Rect) -> bool { + (a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6 + } + + #[test] + fn contours_turn_lines_and_quads_into_cubics() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((3.0, 0.0)); + p.quad_to((3.0, 3.0), (0.0, 3.0)); + p.close_path(); + // After a close, drawing goes on from the start point. + p.line_to((5.0, 5.0)); + p.move_to((9.0, 9.0)); + let cs = contours(&p); + assert_eq!(cs.len(), 2, "{cs:?}"); + assert!(cs[0].closed && !cs[1].closed); + // line, quad and the closing line. + assert_eq!(cs[0].curves(), 3); + assert_eq!(cs[0].pts[1], Point::new(1.0, 0.0)); + assert_eq!(cs[0].pts.last(), cs[0].pts.first()); + let end = Point::new(5.0, 5.0); + assert_eq!(cs[1].pts, vec![Point::ZERO, Point::ZERO.lerp(end, 1.0 / 3.0), Point::ZERO.lerp(end, 2.0 / 3.0), end]); + // The lone move_to at the end makes no contour, and the path comes back the same. + assert_eq!(contours(&to_path(&cs)), cs); + } + + #[test] + fn refine_splits_the_longest_curves_and_keeps_the_shape() { + let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] }; + let mut long = c.clone(); + line_to(&mut long.pts, Point::new(3.0, 30.0)); + let r = refine(&long, 5); + assert_eq!(r.curves(), 5); + // The short first side stays one curve, the long one gets the other four. + assert_eq!(r.pts[3], Point::new(3.0, 0.0)); + assert_eq!(r.pts.last(), Some(&Point::new(3.0, 30.0))); + assert!(r.pts.iter().all(|p| (p.x - 3.0).abs() < 1e-9 || p.y == 0.0)); + assert_eq!(refine(&c, 1), c); + assert_eq!(curves_for(3, 4), 12); + assert_eq!(curves_for(4, 4), 12); + // The common multiple 77 is too many: the larger count, doubled to reach the minimum. + assert_eq!(curves_for(7, 11), 22); + assert_eq!(curves_for(0, 0), 12); + } + + #[test] + fn ellipse_turns_into_a_star_and_lands_exactly() { + let (a, b) = (build("ellipse", 100.0, 100.0, &[]).unwrap(), build("star5", 200.0, 100.0, &[]).unwrap()); + let m = GeometryMorph::new(&a, &b); + let one = Vec2::new(1.0, 1.0); + assert!(near(bbox(&m.at(0.0, one, one).0), bbox(&a))); + assert!(near(bbox(&m.at(1.0, one, one).0), bbox(&b))); + let mid = m.at(0.5, one, one).0; + assert_eq!(mid.paths.len(), 1); + assert!(mid.paths[0].path.elements().iter().all(|e| e.end_point().is_none_or(|p| p.is_finite()))); + // Non-finite progress is the start. + assert!(near(bbox(&m.at(f64::NAN, one, one).0), bbox(&a))); + } + + #[test] + fn triangle_does_not_twist_into_a_circle() { + let (a, b) = (build("triangle", 100.0, 100.0, &[]).unwrap(), build("ellipse", 100.0, 100.0, &[]).unwrap()); + let ca = contours(&a.paths[0].path); + let cb = contours(&b.paths[0].path); + let (x, y) = align(&ca, &cb, None, None); + // Every point travels less than the shapes are wide: no point crosses to the other side. + let far = x[0].pts.iter().zip(&y[0].pts).map(|(p, q)| p.distance(*q)).fold(0.0, f64::max); + assert!(far < 60.0, "{far}"); + } + + #[test] + fn extra_sub_paths_grow_out_of_a_point() { + let (a, b) = (build("rect", 100.0, 100.0, &[]).unwrap(), build("cube", 100.0, 100.0, &[]).unwrap()); + assert!(b.paths.len() > 1); + let m = GeometryMorph::new(&a, &b); + let one = Vec2::new(1.0, 1.0); + let start = m.at(0.0, one, one).0; + assert_eq!(start.paths.len(), b.paths.len()); + for sp in start.paths.iter().skip(1) { + let r = sp.path.bounding_box(); + assert!(r.width() < 1e-9 && r.height() < 1e-9, "{r:?}"); + } + // Looks switch at the midpoint: the cube's shaded faces show from there on. + assert_eq!(m.at(1.0, one, one).0.paths.iter().map(|s| s.fill).collect::>(), b.paths.iter().map(|s| s.fill).collect::>()); + // And the other way round, the faces shrink away. + let back = GeometryMorph::new(&b, &a).at(1.0, one, one).0; + assert!(back.paths.iter().skip(1).all(|sp| sp.path.bounding_box().area() < 1e-9)); + } + + #[test] + fn differing_looks_cross_fade() { + let one = Vec2::new(1.0, 1.0); + let fades = |a: &Geometry, b: &Geometry, t: f64| { + let (g, f) = GeometryMorph::new(a, b).at(t, one, one); + g.paths.iter().zip(f).map(|(s, f)| (s.fill, s.even_odd, s.stroke, f.fill, f.stroke)).collect::>() + }; + // A filled rectangle into a line: the fill fades out, the outline stays. + let (r, line) = (build("rect", 100.0, 100.0, &[]).unwrap(), build("line", 100.0, 0.0, &[]).unwrap()); + assert_eq!(fades(&r, &line, 0.25), [(FillMode::Norm, false, true, 0.75, 1.0)]); + assert_eq!(fades(&line, &r, 0.25), [(FillMode::Norm, false, true, 0.25, 1.0)]); + // A donut into a circle: the donut (even-odd) lies underneath, the circle fades in over its hole. + let (d, e) = (build("donut", 100.0, 100.0, &[]).unwrap(), build("ellipse", 100.0, 100.0, &[]).unwrap()); + assert!(d.paths[0].even_odd && !e.paths[0].even_odd); + assert_eq!(fades(&d, &e, 0.25), [(FillMode::Norm, true, true, 1.0, 1.0), (FillMode::Norm, false, false, 0.25, 0.0)]); + assert_eq!(fades(&e, &d, 0.25), [(FillMode::Norm, true, true, 1.0, 1.0), (FillMode::Norm, false, false, 0.75, 0.0)]); + // Same looks: one layer, fully there. + assert_eq!(fades(&e, &r, 0.5), [(FillMode::Norm, false, true, 1.0, 1.0)]); + } + + #[test] + fn scales_bring_both_sides_into_the_current_box() { + let (a, b) = (build("rect", 10.0, 10.0, &[]).unwrap(), build("ellipse", 40.0, 20.0, &[]).unwrap()); + let m = GeometryMorph::new(&a, &b); + // Current box 20 x 20: a scaled up twice, b to half the width. + let g = m.at(0.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0; + assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0))); + assert!(near(g.text_rect, Rect::new(a.text_rect.x0 * 2.0, a.text_rect.y0 * 2.0, a.text_rect.x1 * 2.0, a.text_rect.y1 * 2.0))); + let g = m.at(1.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0; + assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0))); + } + + #[test] + fn empty_and_degenerate_geometries_do_not_crash() { + let empty = Geometry { paths: vec![], text_rect: Rect::ZERO, handles: vec![], sites: vec![] }; + let r = build("rect", 10.0, 10.0, &[]).unwrap(); + let line = build("line", 10.0, 0.0, &[]).unwrap(); + let mut nan = BezPath::new(); + nan.move_to((f64::NAN, 0.0)); + nan.line_to((1.0, 1.0)); + let bad = Geometry { paths: vec![SubPath { path: nan, fill: FillMode::Norm, stroke: true, even_odd: false }], ..empty.clone() }; + for (x, y) in [(&empty, &empty), (&empty, &r), (&r, &empty), (&r, &line), (&line, &r), (&bad, &r), (&r, &bad)] { + for t in [0.0, 0.5, 1.0, f64::INFINITY] { + let g = GeometryMorph::new(x, y).at(t, Vec2::new(1.0, 1.0), Vec2::new(0.0, 1e300)).0; + // At most two layers per sub-path pair. + assert!(g.paths.len() <= 2 * x.paths.len().max(y.paths.len())); + } + } + // A rectangle into a line: the line is open, so the morph ends open. + let g = GeometryMorph::new(&r, &line).at(1.0, Vec2::new(1.0, 1.0), Vec2::new(1.0, 1.0)).0; + assert!(!g.paths[0].path.elements().iter().any(|e| matches!(e, PathEl::ClosePath))); + } + + #[test] + fn long_contours_sample_their_start_points() { + // A polygon with many corners still aligns (and quickly): the start search is capped. + let mut p = BezPath::new(); + for k in 0..2000 { + let a = k as f64 / 2000.0 * std::f64::consts::TAU; + let q = (50.0 + 50.0 * a.cos(), 50.0 + 50.0 * a.sin()); + if k == 0 { p.move_to(q) } else { p.line_to(q) } + } + p.close_path(); + let many = contours(&p); + let e = contours(&build("ellipse", 100.0, 100.0, &[]).unwrap().paths[0].path); + let (x, y) = align(&many, &e, None, None); + assert_eq!(x[0].curves(), y[0].curves()); + assert_eq!(x[0].curves(), 2000); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 04e5049..950c143 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,7 @@ mod chart; mod images; +mod morph_path; mod morph_text; mod paint; mod placed; @@ -30,6 +31,7 @@ use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::{RenderContext, Resources, peniko}; pub use images::decode as decode_image; +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; @@ -194,11 +196,13 @@ impl Fields for SlideFields { pub struct Renderer { resources: Resources, + /// Outlines morphing in the frame being drawn by `blend`. + paths: Vec, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new() } + Renderer { resources: Resources::new(), paths: Vec::new() } } } @@ -248,12 +252,22 @@ pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Ima /// 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`: /// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or -/// characters ([`TextMorph`]), on top of the later shape of the pair. `opts.state` applies to -/// `shapes` only. -pub fn render_blend(pres: &Presentation, old: usize, new: usize, mix: f64, shapes: &[(Shape, bool)], text: &[TextMorph], opts: &RenderOpts) -> Image { +/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw +/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies +/// to `shapes` only. +pub fn render_blend( + pres: &Presentation, + old: usize, + new: usize, + mix: f64, + shapes: &[(Shape, bool)], + text: &[TextMorph], + paths: &[PathMorph], + opts: &RenderOpts, +) -> Image { let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() }; match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, opts)) + RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts)) })) { Ok(img) => img, Err(_) => { @@ -460,6 +474,7 @@ impl Renderer { mix: f64, shapes: &[(Shape, bool)], text: &[TextMorph], + paths: &[PathMorph], opts: &RenderOpts, ) -> Image { let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 }; @@ -485,6 +500,8 @@ impl Renderer { let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) }; let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) }; let mut drawn: Vec = Vec::new(); + // Set only now: the backdrop's ids may clash with the frame's. + self.paths = paths.to_vec(); for (s, from_old) in shapes { let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) }; let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else { @@ -502,6 +519,7 @@ impl Renderer { morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view); } } + self.paths.clear(); self.finish(ctx, w, h) } @@ -616,7 +634,11 @@ impl Renderer { } fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) { - let geo = shape_geometry(s, w, h); + let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { + Some(pm) => morph_path::geometry(pm, s, w, h), + None => (shape_geometry(s, w, h), Vec::new()), + }; + let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); let (fill, fill_ph) = resolve::fill(rctx, s); let (line, line_ph) = resolve::line(rctx, s); let (effects, fx_ph) = resolve::effects(rctx, s); @@ -662,13 +684,13 @@ impl Renderer { if let Some(fl) = &fill && !(empty_ph && f.opts.edit && s.fill.is_none()) { - for sp in &geo.paths { - if sp.fill == FillMode::None { + for (i, sp) in geo.paths.iter().enumerate() { + if sp.fill == FillMode::None || fade(i).fill <= 0.0 { continue; } ctx.set_transform(m); ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); - paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, 1.0, f.pres); + paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres); ctx.set_fill_rule(peniko::Fill::NonZero); } } @@ -677,8 +699,8 @@ impl Renderer { // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); - for sp in geo.paths.iter().filter(|p| p.stroke) { - stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw); + for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) { + stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw, fade(i).stroke); } if s.is_line() || geo.is_open() { arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw); @@ -833,7 +855,7 @@ fn blur(sigma: f64) -> Filter { Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None }) } -fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, path: &BezPath, m: Affine, lw: f64) { +fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, path: &BezPath, m: Affine, lw: f64, alpha: f64) { use deckcraft_model::style::{LineCap, LineJoin}; let cap = match line.cap.unwrap_or_default() { LineCap::Flat => kurbo::Cap::Butt, @@ -857,13 +879,13 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, pa ctx.set_transform(m); match &line.fill { Some(Fill::Solid { color: c }) => { - ctx.set_paint(color(rctx.color(c, ph), 1.0)); + ctx.set_paint(color(rctx.color(c, ph), alpha)); } Some(Fill::Gradient(g)) => { let c = g.stops.first().map(|s| rctx.color(&s.color, ph)).unwrap_or(Rgba::BLACK); - ctx.set_paint(color(c, 1.0)); + ctx.set_paint(color(c, alpha)); } - _ => ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)), + _ => ctx.set_paint(color(Rgba::BLACK, alpha)), } ctx.set_stroke(sk); ctx.stroke_path(path); diff --git a/crates/render/src/morph_path.rs b/crates/render/src/morph_path.rs new file mode 100644 index 0000000..6579eb1 --- /dev/null +++ b/crates/render/src/morph_path.rs @@ -0,0 +1,40 @@ +//! Morph of an outline: a frame shape whose geometry turns from the old shape's into its own (a +//! circle into a star), with the points aligned by `deckcraft_geom::morph`. + +use deckcraft_geom::Xfrm; +use deckcraft_geom::morph::{Fade, GeometryMorph}; +use deckcraft_geom::preset::Geometry; +use deckcraft_model::{Geom, Shape, ShapeId}; +use kurbo::Vec2; + +use crate::shape_geometry; + +/// A frame shape (see [`crate::render_blend`]) whose outline morphs from `from` into its own +/// geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct PathMorph { + pub id: ShapeId, + /// The old shape's geometry. + pub from: Geom, + /// The boxes of the old and the new shape on their own slides. + pub from_box: Xfrm, + pub to_box: Xfrm, + /// Progress, 0..1 (already eased). + pub t: f64, +} + +/// `now / own`, or 1 when that is no usable scale (a line's zero height). +fn ratio(now: f64, own: f64) -> f64 { + let r = now / own; + if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 } +} + +/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their +/// own sizes (so the alignment does not change while the box grows), then scaled into the box. +/// With a [`Fade`] per sub-path. +pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec) { + let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h); + let b = shape_geometry(s, pm.to_box.w, pm.to_box.h); + let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h)); + GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box)) +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e8900b8..1f7a713 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -206,14 +206,14 @@ fn blend_ends_match_the_slides_and_mixes_backdrops() { let o = RenderOpts { scale: 0.5, ..Default::default() }; let shapes = vec![(sh, false)]; // At the ends the frame is the slide itself; the shape stays opaque throughout. - assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &o).pixels, render_slide(&p, 1, &o).pixels); - let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &o); + assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels); + let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o); let px = mid.pixel(5, 5); assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}"); assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75)); // Hostile input: bad indices and NaN never panic. - assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &o).width, 0); - let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &o); + assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0); + let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o); } fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape { @@ -258,7 +258,7 @@ fn morph_text_ends_match_the_slides() { sb.xfrm = Some(x); sa.xfrm = Some(x); let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t }; - render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &o) + render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o) }; // Warm up: faces load lazily (a background scan runs), so the first render may still fall back. let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5)); @@ -273,6 +273,72 @@ fn morph_text_ends_match_the_slides() { assert!(ink > 50, "ink {ink}"); } +#[test] +fn morph_path_ends_match_the_slides() { + let from = Xfrm::new(100.0, 100.0, 200.0, 200.0); + let to = Xfrm::new(500.0, 150.0, 300.0, 150.0); + let form = |id: u32, g: &str, x: Xfrm| Shape { + id: ShapeId(id), + xfrm: Some(x), + geom: Geom::preset(g), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + let mut p = deck_with(vec![form(1, "ellipse", from)]); + let mut b = (*p.slides[0]).clone(); + b.shapes = vec![form(1, "star5", to)]; + p.slides.push(std::sync::Arc::new(b)); + let o = RenderOpts { scale: 0.5, ..Default::default() }; + let frame = |t: f64| { + let lerp = |a: f64, b: f64| a + (b - a) * t; + let x = Xfrm::new(lerp(from.x, to.x), lerp(from.y, to.y), lerp(from.w, to.w), lerp(from.h, to.h)); + let pm = PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t }; + render_blend(&p, 0, 1, t, &[(form(10, "star5", x), false)], &[], &[pm], &o) + }; + // The morph splits the outlines into more curves, which flatten slightly differently: edge pixels + // may differ a little, a different shape differs a lot. + let differ = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 60).count(); + // At the start the star is still the circle, at the end the star. + let d0 = differ(&frame(0.0), &render_slide(&p, 0, &o)); + assert!(d0 < 40, "start differs {d0}"); + let d1 = differ(&frame(1.0), &render_slide(&p, 1, &o)); + assert!(d1 < 40, "end differs {d1}"); + // Without the morph the start would show the star. + let star = render_blend(&p, 0, 1, 0.0, &[(form(10, "star5", from), false)], &[], &[], &o); + assert!(differ(&star, &render_slide(&p, 0, &o)) > 400); + // Halfway the shape is filled around the middle of its box. + let mid = frame(0.5); + let c = (from.center().to_vec2() + to.center().to_vec2()) * 0.5 * 0.5; + assert_ne!(mid.pixel(c.x as u32, c.y as u32), [255, 255, 255, 255]); +} + +#[test] +fn morph_path_fades_a_fill_only_one_side_has() { + let bx = Xfrm::new(100.0, 100.0, 200.0, 200.0); + let form = |id: u32, g: &str| Shape { + id: ShapeId(id), + xfrm: Some(bx), + geom: Geom::preset(g), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + let mut p = deck_with(vec![form(1, "rect")]); + let mut b = (*p.slides[0]).clone(); + b.shapes = vec![form(1, "line")]; + p.slides.push(std::sync::Arc::new(b)); + let o = RenderOpts { scale: 0.5, ..Default::default() }; + let at = |t: f64| { + let pm = PathMorph { id: ShapeId(10), from: Geom::preset("rect"), from_box: bx, to_box: bx, t }; + // A point near the line but not on it: inside the shape all the way. + render_blend(&p, 0, 1, t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90) + }; + let (full, half, none) = (at(0.0), at(0.5), at(1.0)); + assert_eq!(none, [255, 255, 255, 255]); + assert!(full[0] < 128, "{full:?}"); + // Halfway the fill is half there: between the full colour and the white slide. + assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}"); +} + #[test] fn text_transform_unmirrors_flips() { // Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text. diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index fd7b3ce..60b5b39 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -422,7 +422,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { .iter() .map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }) .collect(); - let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &opts); + let paths: Vec = m + .paths + .iter() + .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) + .collect(); + let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts); let ci = crate::textures::to_color_image(&img, false); let frame = match tr.morph.take() { Some(mut h) => {