//! 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); } }