Skip to main content

deckcraft_geom/
morph.rs

1//! Morph between two outlines: one shape turns into another (a circle into a star).
2//!
3//! The approach is manim's: both outlines get the same number of contours and each contour pair the
4//! same number of cubic curves, then every control point moves in a straight line. On top of that,
5//! closed contours are turned and reversed so that their points travel as short a way as possible
6//! (a triangle does not twist on its way into a circle), and extra points go to the longest curves.
7
8use kurbo::{BezPath, PathEl, Point, Rect, Vec2};
9
10use crate::preset::{FillMode, Geometry, SubPath};
11
12/// A contour as cubic curves: the start anchor, then three points per curve (handle, handle,
13/// anchor).
14#[derive(Clone, Debug, PartialEq)]
15pub struct Contour {
16    pub closed: bool,
17    pub pts: Vec<Point>,
18}
19
20impl Contour {
21    pub fn curves(&self) -> usize {
22        self.pts.len().saturating_sub(1) / 3
23    }
24}
25
26fn line_to(p: &mut Vec<Point>, to: Point) {
27    let Some(&from) = p.last() else { return };
28    p.extend([from.lerp(to, 1.0 / 3.0), from.lerp(to, 2.0 / 3.0), to]);
29}
30
31fn close(c: &mut Contour) {
32    if let (Some(&first), Some(&last)) = (c.pts.first(), c.pts.last())
33        && first != last
34    {
35        line_to(&mut c.pts, first);
36    }
37    c.closed = true;
38}
39
40fn pen(cur: &mut Option<Contour>, start: Point) -> &mut Contour {
41    cur.get_or_insert_with(|| Contour {
42        closed: false,
43        pts: vec![start],
44    })
45}
46
47fn keep(c: Contour, out: &mut Vec<Contour>) {
48    if c.curves() > 0 && c.pts.iter().all(|p| p.is_finite()) {
49        out.push(c);
50    }
51}
52
53/// The contours of a path, every segment as a cubic curve. Contours without a curve or with
54/// non-finite points are left out.
55pub fn contours(path: &BezPath) -> Vec<Contour> {
56    let mut out = Vec::new();
57    let mut cur: Option<Contour> = None;
58    let mut start = Point::ZERO;
59    for el in path.elements() {
60        match *el {
61            PathEl::MoveTo(p) => {
62                if let Some(c) = cur.take() {
63                    keep(c, &mut out);
64                }
65                start = p;
66                cur = Some(Contour {
67                    closed: false,
68                    pts: vec![p],
69                });
70            }
71            PathEl::LineTo(p) => line_to(&mut pen(&mut cur, start).pts, p),
72            PathEl::QuadTo(q, p) => {
73                let c = pen(&mut cur, start);
74                if let Some(&a) = c.pts.last() {
75                    c.pts
76                        .extend([a.lerp(q, 2.0 / 3.0), p.lerp(q, 2.0 / 3.0), p]);
77                }
78            }
79            PathEl::CurveTo(a, b, p) => pen(&mut cur, start).pts.extend([a, b, p]),
80            PathEl::ClosePath => {
81                if let Some(mut c) = cur.take()
82                    && c.curves() > 0
83                {
84                    close(&mut c);
85                    keep(c, &mut out);
86                }
87            }
88        }
89    }
90    if let Some(c) = cur {
91        keep(c, &mut out);
92    }
93    out
94}
95
96/// Contours back to a path.
97pub fn to_path(cs: &[Contour]) -> BezPath {
98    let mut bp = BezPath::new();
99    for c in cs {
100        let Some(&s) = c.pts.first() else { continue };
101        bp.move_to(s);
102        for [a, b, p] in c.pts.get(1..).unwrap_or(&[]).as_chunks::<3>().0 {
103            bp.curve_to(*a, *b, *p);
104        }
105        if c.closed {
106            bp.close_path();
107        }
108    }
109    bp
110}
111
112// --- alignment ---------------------------------------------------------------------------------
113
114/// Rough curve length: mean of chord and control polygon.
115fn curve_length(c: &[Point]) -> f64 {
116    let [p0, p1, p2, p3] = c else { return 0.0 };
117    (p0.distance(*p3) + p0.distance(*p1) + p1.distance(*p2) + p2.distance(*p3)) / 2.0
118}
119
120/// Appends curve `c` (four points) split into `n` pieces of equal parameter length (de Casteljau),
121/// without its start anchor.
122fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
123    let [a, b, d, e] = c else { return };
124    let (mut p0, mut p1, mut p2, p3) = (*a, *b, *d, *e);
125    for j in (2..=n).rev() {
126        let t = 1.0 / j as f64;
127        let (ab, bc, cd) = (p0.lerp(p1, t), p1.lerp(p2, t), p2.lerp(p3, t));
128        let (abc, bcd) = (ab.lerp(bc, t), bc.lerp(cd, t));
129        let m = abc.lerp(bcd, t);
130        out.extend([ab, abc, m]);
131        (p0, p1, p2) = (m, bcd, cd);
132    }
133    out.extend([p1, p2, p3]);
134}
135
136/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
137/// splits go to the longest pieces, so a long side gets more points than a short one.
138fn refine(c: &Contour, n: usize) -> Contour {
139    let have = c.curves();
140    if have >= n || have == 0 {
141        return c.clone();
142    }
143    let curves: Vec<&[Point]> = (0..have)
144        .filter_map(|k| c.pts.get(3 * k..3 * k + 4))
145        .collect();
146    let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
147    let mut parts = vec![1usize; curves.len()];
148    for _ in 0..n - have {
149        let share = |k: usize| {
150            len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64
151        };
152        let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
153        if let Some(p) = parts.get_mut(best) {
154            *p += 1;
155        }
156    }
157    let mut out = Vec::with_capacity(1 + 3 * n);
158    out.extend(c.pts.first().copied());
159    for (q, p) in curves.iter().zip(&parts) {
160        split_curve(q, *p, &mut out);
161    }
162    Contour {
163        closed: c.closed,
164        pts: out,
165    }
166}
167
168const MIN_CURVES: usize = 12;
169const MAX_CURVES: usize = 60;
170/// Start points tried per closed contour; longer contours are sampled evenly.
171const MAX_TRIES: usize = 128;
172
173/// How many curves a contour pair gets: a common multiple of both counts while that stays small
174/// (then a triangle meets a circle at matching places and the morph stays symmetric), and at least
175/// [`MIN_CURVES`] for a smooth middle.
176fn curves_for(na: usize, nb: usize) -> usize {
177    fn gcd(x: usize, y: usize) -> usize {
178        if y == 0 { x } else { gcd(y, x % y) }
179    }
180    let hi = na.max(nb).max(1);
181    let mut n = if na > 0 && nb > 0 {
182        (na / gcd(na, nb)).saturating_mul(nb)
183    } else {
184        hi
185    };
186    if n > MAX_CURVES {
187        n = hi;
188    }
189    n.saturating_mul(MIN_CURVES.div_ceil(n).max(1))
190}
191
192/// Box around all points, handles included.
193fn bounds<'a>(pts: impl IntoIterator<Item = &'a Point>) -> Option<Rect> {
194    pts.into_iter().fold(None, |r: Option<Rect>, p| {
195        Some(r.map_or(Rect::from_points(*p, *p), |r| r.union_pt(*p)))
196    })
197}
198
199fn center(pts: &[Point]) -> Point {
200    bounds(pts).map(|r| r.center()).unwrap_or(Point::ZERO)
201}
202
203/// The same contour, starting at anchor `s`, optionally run backwards.
204fn turn(pts: &[Point], s: usize, back: bool) -> Vec<Point> {
205    let q: Vec<Point> = if back {
206        pts.iter().rev().copied().collect()
207    } else {
208        pts.to_vec()
209    };
210    if s == 0 {
211        return q;
212    }
213    let body = q.get(1..).unwrap_or(&[]);
214    let cut = (3 * s).min(body.len());
215    let mut out = Vec::with_capacity(q.len());
216    out.extend(cut.checked_sub(1).and_then(|i| body.get(i)).copied());
217    out.extend_from_slice(body.get(cut..).unwrap_or(&[]));
218    out.extend_from_slice(body.get(..cut).unwrap_or(&[]));
219    out
220}
221
222/// One contour pair of equal curve count: `b` runs backwards if `back` is set, then the closed side
223/// (`b` if it is closed, else `a`) gets the start that lets the points travel least. Both sides are
224/// compared around their own centre, so where the shapes are does not matter.
225fn match_pair(a: &Contour, b: &Contour, back: bool) -> (Contour, Contour, f64) {
226    let bp = if back {
227        turn(&b.pts, 0, true)
228    } else {
229        b.pts.clone()
230    };
231    let turn_a = !b.closed && a.closed;
232    let (fixed, free) = if turn_a { (&bp, &a.pts) } else { (&a.pts, &bp) };
233    let (fc, gc) = (center(fixed), center(free));
234    let tries = if turn_a || b.closed {
235        free.len().saturating_sub(1) / 3
236    } else {
237        1
238    };
239    let mut best = free.clone();
240    let mut best_cost = f64::INFINITY;
241    for s in (0..tries.max(1)).step_by(tries.div_ceil(MAX_TRIES).max(1)) {
242        let q = turn(free, s, false);
243        let mut cost = 0.0;
244        for (p, f) in q.iter().zip(fixed.iter()) {
245            cost += ((*p - gc) - (*f - fc)).hypot2();
246            if cost >= best_cost {
247                break;
248            }
249        }
250        if cost < best_cost {
251            best_cost = cost;
252            best = q;
253        }
254    }
255    let (pa, pb) = if turn_a {
256        (best, bp)
257    } else {
258        (a.pts.clone(), best)
259    };
260    (
261        Contour {
262            closed: a.closed,
263            pts: pa,
264        },
265        Contour {
266            closed: b.closed,
267            pts: pb,
268        },
269        best_cost,
270    )
271}
272
273/// Where a contour without partner shrinks to (or grows from): the spot at the same place relative
274/// to the other outline's box, or its own centre without one.
275fn place(c: &Contour, from: Option<Rect>, to: Option<Rect>) -> Point {
276    let p = center(&c.pts);
277    let (Some(from), Some(to)) = (from, to) else {
278        return p;
279    };
280    let fx = if from.width() > 0.0 {
281        (p.x - from.x0) / from.width()
282    } else {
283        0.5
284    };
285    let fy = if from.height() > 0.0 {
286        (p.y - from.y0) / from.height()
287    } else {
288        0.5
289    };
290    Point::new(to.x0 + to.width() * fx, to.y0 + to.height() * fy)
291}
292
293/// Two contour lists brought to the same number of contours and the same number of curves per
294/// contour pair. Contours pair up in order; one without partner meets a point (see [`place`];
295/// `ba`/`bb` are the boxes of the whole outlines). Whether `b` runs backwards is decided once for all
296/// its contours: turning single ones around would change which areas a nonzero fill covers.
297pub fn align(
298    a: &[Contour],
299    b: &[Contour],
300    ba: Option<Rect>,
301    bb: Option<Rect>,
302) -> (Vec<Contour>, Vec<Contour>) {
303    // (a, b, either side a point).
304    let mut pairs: Vec<(Contour, Contour, bool)> = Vec::new();
305    for k in 0..a.len().max(b.len()) {
306        let (ca, cb) = (a.get(k), b.get(k));
307        let n = curves_for(ca.map_or(0, Contour::curves), cb.map_or(0, Contour::curves));
308        let point = |c: &Contour, from, to, closed| Contour {
309            closed,
310            pts: vec![place(c, from, to); 1 + 3 * n],
311        };
312        match (ca, cb) {
313            (Some(x), Some(y)) => pairs.push((refine(x, n), refine(y, n), false)),
314            (Some(x), None) => pairs.push((refine(x, n), point(x, ba, bb, x.closed), true)),
315            (None, Some(y)) => pairs.push((point(y, bb, ba, y.closed), refine(y, n), true)),
316            (None, None) => {}
317        }
318    }
319    let mut best: Option<(Vec<Contour>, Vec<Contour>, f64)> = None;
320    for back in [false, true] {
321        let (mut xs, mut ys, mut cost) = (Vec::new(), Vec::new(), 0.0);
322        for (x, y, point) in &pairs {
323            if *point {
324                xs.push(x.clone());
325                ys.push(if back {
326                    Contour {
327                        closed: y.closed,
328                        pts: turn(&y.pts, 0, true),
329                    }
330                } else {
331                    y.clone()
332                });
333            } else {
334                let (mx, my, c) = match_pair(x, y, back);
335                xs.push(mx);
336                ys.push(my);
337                cost += c;
338            }
339        }
340        if best.as_ref().is_none_or(|b| cost < b.2) {
341            best = Some((xs, ys, cost));
342        }
343    }
344    best.map(|(x, y, _)| (x, y)).unwrap_or_default()
345}
346
347// --- whole geometries ----------------------------------------------------------------------------
348
349#[derive(Clone, Copy, Debug, PartialEq)]
350struct Look {
351    fill: FillMode,
352    stroke: bool,
353    even_odd: bool,
354}
355
356#[derive(Clone, Debug)]
357struct Part {
358    a: Option<Look>,
359    b: Option<Look>,
360    ca: Vec<Contour>,
361    cb: Vec<Contour>,
362}
363
364/// Two shape geometries brought to the same structure once, then blended per frame with
365/// [`GeometryMorph::at`]. Sub-paths pair up in order; one without partner grows out of (or shrinks
366/// into) a point. When a pair differs in fill mode, outline or fill rule, the two looks cross-fade
367/// (see [`Fade`]).
368#[derive(Clone, Debug)]
369pub struct GeometryMorph {
370    parts: Vec<Part>,
371    text: (Rect, Rect),
372}
373
374fn look(s: &SubPath) -> Look {
375    Look {
376        fill: s.fill,
377        stroke: s.stroke,
378        even_odd: s.even_odd,
379    }
380}
381
382/// How strongly a sub-path of a morphed geometry shows: its fill and its outline, 0..1.
383#[derive(Clone, Copy, Debug, PartialEq)]
384pub struct Fade {
385    pub fill: f64,
386    pub stroke: f64,
387}
388
389/// The layers a pair of looks is drawn with at `t`, bottom first. A fill or an outline only one side
390/// has fades out (or in). When both fill, the side with the even-odd rule lies opaque underneath and
391/// the other fades over it, so a donut's hole fills smoothly; with the same rule both cover the same
392/// area, so this is a plain cross-fade (shading of 3-D faces).
393fn layers(a: Option<Look>, b: Option<Look>, t: f64) -> Vec<(Look, Fade)> {
394    let (a, b) = match (a, b) {
395        (Some(a), Some(b)) if a != b => (a, b),
396        (Some(x), _) | (None, Some(x)) => {
397            return vec![(
398                x,
399                Fade {
400                    fill: 1.0,
401                    stroke: 1.0,
402                },
403            )];
404        }
405        (None, None) => return vec![],
406    };
407    let side = |on_a: bool, on_b: bool| match (on_a, on_b) {
408        (true, true) => 1.0,
409        (true, false) => 1.0 - t,
410        (false, true) => t,
411        (false, false) => 0.0,
412    };
413    let (stroke, line) = (a.stroke || b.stroke, side(a.stroke, b.stroke));
414    let (fa, fb) = (a.fill != FillMode::None, b.fill != FillMode::None);
415    if fa && fb {
416        let (under, over, alpha) = if a.even_odd || !b.even_odd {
417            (a, b, t)
418        } else {
419            (b, a, 1.0 - t)
420        };
421        vec![
422            (
423                Look { stroke, ..under },
424                Fade {
425                    fill: 1.0,
426                    stroke: line,
427                },
428            ),
429            (
430                Look {
431                    stroke: false,
432                    ..over
433                },
434                Fade {
435                    fill: alpha,
436                    stroke: 0.0,
437                },
438            ),
439        ]
440    } else {
441        let filled = if fa { a } else { b };
442        vec![(
443            Look { stroke, ..filled },
444            Fade {
445                fill: side(fa, fb),
446                stroke: line,
447            },
448        )]
449    }
450}
451
452impl GeometryMorph {
453    /// `a` and `b` each in their own shape-local space.
454    pub fn new(a: &Geometry, b: &Geometry) -> Self {
455        let ca: Vec<Vec<Contour>> = a.paths.iter().map(|s| contours(&s.path)).collect();
456        let cb: Vec<Vec<Contour>> = b.paths.iter().map(|s| contours(&s.path)).collect();
457        let ba = bounds(ca.iter().flatten().flat_map(|c| &c.pts));
458        let bb = bounds(cb.iter().flatten().flat_map(|c| &c.pts));
459        let mut parts = Vec::new();
460        for k in 0..a.paths.len().max(b.paths.len()) {
461            let (xa, xb) = (
462                ca.get(k).map(Vec::as_slice).unwrap_or(&[]),
463                cb.get(k).map(Vec::as_slice).unwrap_or(&[]),
464            );
465            let (pa, pb) = align(xa, xb, ba, bb);
466            parts.push(Part {
467                a: a.paths.get(k).map(look),
468                b: b.paths.get(k).map(look),
469                ca: pa,
470                cb: pb,
471            });
472        }
473        GeometryMorph {
474            parts,
475            text: (a.text_rect, b.text_rect),
476        }
477    }
478
479    /// The geometry at `t` (0..1), with a [`Fade`] for each of its sub-paths. The points of `a` are
480    /// first scaled by `sa` and those of `b` by `sb`, to bring both into the current box.
481    pub fn at(&self, t: f64, sa: Vec2, sb: Vec2) -> (Geometry, Vec<Fade>) {
482        let t = if t.is_finite() {
483            t.clamp(0.0, 1.0)
484        } else {
485            0.0
486        };
487        let scale = |p: Point, s: Vec2| Point::new(p.x * s.x, p.y * s.y);
488        let (mut paths, mut fades) = (Vec::new(), Vec::new());
489        for part in &self.parts {
490            let cs: Vec<Contour> = part
491                .ca
492                .iter()
493                .zip(&part.cb)
494                .map(|(x, y)| Contour {
495                    closed: x.closed && y.closed,
496                    pts: x
497                        .pts
498                        .iter()
499                        .zip(&y.pts)
500                        .map(|(p, q)| scale(*p, sa).lerp(scale(*q, sb), t))
501                        .collect(),
502                })
503                .collect();
504            let path = to_path(&cs);
505            for (lk, fade) in layers(part.a, part.b, t) {
506                paths.push(SubPath {
507                    path: path.clone(),
508                    fill: lk.fill,
509                    stroke: lk.stroke,
510                    even_odd: lk.even_odd,
511                });
512                fades.push(fade);
513            }
514        }
515        let r = |r: Rect, s: Vec2| Rect::new(r.x0 * s.x, r.y0 * s.y, r.x1 * s.x, r.y1 * s.y);
516        let (ta, tb) = (r(self.text.0, sa), r(self.text.1, sb));
517        let text_rect = Rect::from_points(
518            ta.origin().lerp(tb.origin(), t),
519            Point::new(ta.x1, ta.y1).lerp(Point::new(tb.x1, tb.y1), t),
520        );
521        (
522            Geometry {
523                paths,
524                text_rect,
525                handles: vec![],
526                sites: vec![],
527            },
528            fades,
529        )
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::preset::build;
537    use kurbo::Shape as _;
538
539    fn bbox(g: &Geometry) -> Rect {
540        g.outline().bounding_box()
541    }
542
543    fn near(a: Rect, b: Rect) -> bool {
544        (a.x0 - b.x0).abs() < 1e-6
545            && (a.y0 - b.y0).abs() < 1e-6
546            && (a.x1 - b.x1).abs() < 1e-6
547            && (a.y1 - b.y1).abs() < 1e-6
548    }
549
550    #[test]
551    fn contours_turn_lines_and_quads_into_cubics() {
552        let mut p = BezPath::new();
553        p.move_to((0.0, 0.0));
554        p.line_to((3.0, 0.0));
555        p.quad_to((3.0, 3.0), (0.0, 3.0));
556        p.close_path();
557        // After a close, drawing goes on from the start point.
558        p.line_to((5.0, 5.0));
559        p.move_to((9.0, 9.0));
560        let cs = contours(&p);
561        assert_eq!(cs.len(), 2, "{cs:?}");
562        assert!(cs[0].closed && !cs[1].closed);
563        // line, quad and the closing line.
564        assert_eq!(cs[0].curves(), 3);
565        assert_eq!(cs[0].pts[1], Point::new(1.0, 0.0));
566        assert_eq!(cs[0].pts.last(), cs[0].pts.first());
567        let end = Point::new(5.0, 5.0);
568        assert_eq!(
569            cs[1].pts,
570            vec![
571                Point::ZERO,
572                Point::ZERO.lerp(end, 1.0 / 3.0),
573                Point::ZERO.lerp(end, 2.0 / 3.0),
574                end
575            ]
576        );
577        // The lone move_to at the end makes no contour, and the path comes back the same.
578        assert_eq!(contours(&to_path(&cs)), cs);
579    }
580
581    #[test]
582    fn refine_splits_the_longest_curves_and_keeps_the_shape() {
583        let c = Contour {
584            closed: false,
585            pts: vec![
586                Point::ZERO,
587                Point::new(1.0, 0.0),
588                Point::new(2.0, 0.0),
589                Point::new(3.0, 0.0),
590            ],
591        };
592        let mut long = c.clone();
593        line_to(&mut long.pts, Point::new(3.0, 30.0));
594        let r = refine(&long, 5);
595        assert_eq!(r.curves(), 5);
596        // The short first side stays one curve, the long one gets the other four.
597        assert_eq!(r.pts[3], Point::new(3.0, 0.0));
598        assert_eq!(r.pts.last(), Some(&Point::new(3.0, 30.0)));
599        assert!(r.pts.iter().all(|p| (p.x - 3.0).abs() < 1e-9 || p.y == 0.0));
600        assert_eq!(refine(&c, 1), c);
601        assert_eq!(curves_for(3, 4), 12);
602        assert_eq!(curves_for(4, 4), 12);
603        // The common multiple 77 is too many: the larger count, doubled to reach the minimum.
604        assert_eq!(curves_for(7, 11), 22);
605        assert_eq!(curves_for(0, 0), 12);
606    }
607
608    #[test]
609    fn ellipse_turns_into_a_star_and_lands_exactly() {
610        let (a, b) = (
611            build("ellipse", 100.0, 100.0, &[]).unwrap(),
612            build("star5", 200.0, 100.0, &[]).unwrap(),
613        );
614        let m = GeometryMorph::new(&a, &b);
615        let one = Vec2::new(1.0, 1.0);
616        assert!(near(bbox(&m.at(0.0, one, one).0), bbox(&a)));
617        assert!(near(bbox(&m.at(1.0, one, one).0), bbox(&b)));
618        let mid = m.at(0.5, one, one).0;
619        assert_eq!(mid.paths.len(), 1);
620        assert!(
621            mid.paths[0]
622                .path
623                .elements()
624                .iter()
625                .all(|e| e.end_point().is_none_or(|p| p.is_finite()))
626        );
627        // Non-finite progress is the start.
628        assert!(near(bbox(&m.at(f64::NAN, one, one).0), bbox(&a)));
629    }
630
631    #[test]
632    fn triangle_does_not_twist_into_a_circle() {
633        let (a, b) = (
634            build("triangle", 100.0, 100.0, &[]).unwrap(),
635            build("ellipse", 100.0, 100.0, &[]).unwrap(),
636        );
637        let ca = contours(&a.paths[0].path);
638        let cb = contours(&b.paths[0].path);
639        let (x, y) = align(&ca, &cb, None, None);
640        // Every point travels less than the shapes are wide: no point crosses to the other side.
641        let far = x[0]
642            .pts
643            .iter()
644            .zip(&y[0].pts)
645            .map(|(p, q)| p.distance(*q))
646            .fold(0.0, f64::max);
647        assert!(far < 60.0, "{far}");
648    }
649
650    #[test]
651    fn extra_sub_paths_grow_out_of_a_point() {
652        let (a, b) = (
653            build("rect", 100.0, 100.0, &[]).unwrap(),
654            build("cube", 100.0, 100.0, &[]).unwrap(),
655        );
656        assert!(b.paths.len() > 1);
657        let m = GeometryMorph::new(&a, &b);
658        let one = Vec2::new(1.0, 1.0);
659        let start = m.at(0.0, one, one).0;
660        assert_eq!(start.paths.len(), b.paths.len());
661        for sp in start.paths.iter().skip(1) {
662            let r = sp.path.bounding_box();
663            assert!(r.width() < 1e-9 && r.height() < 1e-9, "{r:?}");
664        }
665        // Looks switch at the midpoint: the cube's shaded faces show from there on.
666        assert_eq!(
667            m.at(1.0, one, one)
668                .0
669                .paths
670                .iter()
671                .map(|s| s.fill)
672                .collect::<Vec<_>>(),
673            b.paths.iter().map(|s| s.fill).collect::<Vec<_>>()
674        );
675        // And the other way round, the faces shrink away.
676        let back = GeometryMorph::new(&b, &a).at(1.0, one, one).0;
677        assert!(
678            back.paths
679                .iter()
680                .skip(1)
681                .all(|sp| sp.path.bounding_box().area() < 1e-9)
682        );
683    }
684
685    #[test]
686    fn differing_looks_cross_fade() {
687        let one = Vec2::new(1.0, 1.0);
688        let fades = |a: &Geometry, b: &Geometry, t: f64| {
689            let (g, f) = GeometryMorph::new(a, b).at(t, one, one);
690            g.paths
691                .iter()
692                .zip(f)
693                .map(|(s, f)| (s.fill, s.even_odd, s.stroke, f.fill, f.stroke))
694                .collect::<Vec<_>>()
695        };
696        // A filled rectangle into a line: the fill fades out, the outline stays.
697        let (r, line) = (
698            build("rect", 100.0, 100.0, &[]).unwrap(),
699            build("line", 100.0, 0.0, &[]).unwrap(),
700        );
701        assert_eq!(
702            fades(&r, &line, 0.25),
703            [(FillMode::Norm, false, true, 0.75, 1.0)]
704        );
705        assert_eq!(
706            fades(&line, &r, 0.25),
707            [(FillMode::Norm, false, true, 0.25, 1.0)]
708        );
709        // A donut into a circle: the donut (even-odd) lies underneath, the circle fades in over its hole.
710        let (d, e) = (
711            build("donut", 100.0, 100.0, &[]).unwrap(),
712            build("ellipse", 100.0, 100.0, &[]).unwrap(),
713        );
714        assert!(d.paths[0].even_odd && !e.paths[0].even_odd);
715        assert_eq!(
716            fades(&d, &e, 0.25),
717            [
718                (FillMode::Norm, true, true, 1.0, 1.0),
719                (FillMode::Norm, false, false, 0.25, 0.0)
720            ]
721        );
722        assert_eq!(
723            fades(&e, &d, 0.25),
724            [
725                (FillMode::Norm, true, true, 1.0, 1.0),
726                (FillMode::Norm, false, false, 0.75, 0.0)
727            ]
728        );
729        // Same looks: one layer, fully there.
730        assert_eq!(
731            fades(&e, &r, 0.5),
732            [(FillMode::Norm, false, true, 1.0, 1.0)]
733        );
734    }
735
736    #[test]
737    fn scales_bring_both_sides_into_the_current_box() {
738        let (a, b) = (
739            build("rect", 10.0, 10.0, &[]).unwrap(),
740            build("ellipse", 40.0, 20.0, &[]).unwrap(),
741        );
742        let m = GeometryMorph::new(&a, &b);
743        // Current box 20 x 20: a scaled up twice, b to half the width.
744        let g = m.at(0.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0;
745        assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0)));
746        assert!(near(
747            g.text_rect,
748            Rect::new(
749                a.text_rect.x0 * 2.0,
750                a.text_rect.y0 * 2.0,
751                a.text_rect.x1 * 2.0,
752                a.text_rect.y1 * 2.0
753            )
754        ));
755        let g = m.at(1.0, Vec2::new(2.0, 2.0), Vec2::new(0.5, 1.0)).0;
756        assert!(near(bbox(&g), Rect::new(0.0, 0.0, 20.0, 20.0)));
757    }
758
759    #[test]
760    fn empty_and_degenerate_geometries_do_not_crash() {
761        let empty = Geometry {
762            paths: vec![],
763            text_rect: Rect::ZERO,
764            handles: vec![],
765            sites: vec![],
766        };
767        let r = build("rect", 10.0, 10.0, &[]).unwrap();
768        let line = build("line", 10.0, 0.0, &[]).unwrap();
769        let mut nan = BezPath::new();
770        nan.move_to((f64::NAN, 0.0));
771        nan.line_to((1.0, 1.0));
772        let bad = Geometry {
773            paths: vec![SubPath {
774                path: nan,
775                fill: FillMode::Norm,
776                stroke: true,
777                even_odd: false,
778            }],
779            ..empty.clone()
780        };
781        for (x, y) in [
782            (&empty, &empty),
783            (&empty, &r),
784            (&r, &empty),
785            (&r, &line),
786            (&line, &r),
787            (&bad, &r),
788            (&r, &bad),
789        ] {
790            for t in [0.0, 0.5, 1.0, f64::INFINITY] {
791                let g = GeometryMorph::new(x, y)
792                    .at(t, Vec2::new(1.0, 1.0), Vec2::new(0.0, 1e300))
793                    .0;
794                // At most two layers per sub-path pair.
795                assert!(g.paths.len() <= 2 * x.paths.len().max(y.paths.len()));
796            }
797        }
798        // A rectangle into a line: the line is open, so the morph ends open.
799        let g = GeometryMorph::new(&r, &line)
800            .at(1.0, Vec2::new(1.0, 1.0), Vec2::new(1.0, 1.0))
801            .0;
802        assert!(
803            !g.paths[0]
804                .path
805                .elements()
806                .iter()
807                .any(|e| matches!(e, PathEl::ClosePath))
808        );
809    }
810
811    #[test]
812    fn long_contours_sample_their_start_points() {
813        // A polygon with many corners still aligns (and quickly): the start search is capped.
814        let mut p = BezPath::new();
815        for k in 0..2000 {
816            let a = k as f64 / 2000.0 * std::f64::consts::TAU;
817            let q = (50.0 + 50.0 * a.cos(), 50.0 + 50.0 * a.sin());
818            if k == 0 { p.move_to(q) } else { p.line_to(q) }
819        }
820        p.close_path();
821        let many = contours(&p);
822        let e = contours(&build("ellipse", 100.0, 100.0, &[]).unwrap().paths[0].path);
823        let (x, y) = align(&many, &e, None, None);
824        assert_eq!(x[0].curves(), y[0].curves());
825        assert_eq!(x[0].curves(), 2000);
826    }
827}