Skip to main content

motion_core/path2d/
morph.rs

1//! Bounded path morphing with exact cubic source geometry.
2
3use kurbo::ParamCurveArea;
4use std::sync::Arc;
5
6use crate::{Interpolate, MAX_PATH_VERBS, Path2D, PathVerb, Point, Rect, Shape, ValidationError};
7
8const MAX_CONTOURS: usize = 64;
9
10const DEFAULT_SAMPLES: usize = 32;
11
12const MAX_SAMPLES: usize = 256;
13
14const MAX_ALIGNMENT_WORK: usize = 16_777_216;
15
16/// A checked path value for geometry interpolation and fill/stroke.
17/// Original path snapshots are retained at exact animation endpoints.
18///
19/// ```
20/// use motion_core::prelude::*;
21/// use motion_core::{
22///     DisplayList, RasterLimits, ResourceStore, ValidatedDisplayList, MAX_PATH_VERBS,
23///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
24/// };
25/// let (from, to) = MorphPath::pair(
26///     Circle::new(Point::new(8.0, 8.0), 6.0)?.path()?,
27///     Star::new(Point::new(8.0, 8.0), 5, 6.0, 2.5)?.path()?,
28/// )?;
29/// let animation = Tween::new(from, to, 1.seconds());
30/// let middle = animation.sample(500.milliseconds().after_origin());
31/// assert!(middle.bounding_box()?.is_finite());
32/// # Ok::<(), motion_core::Error>(())
33/// ```
34#[derive(Clone, Debug)]
35pub struct MorphPath {
36    path: Path2D,
37    contours: Arc<[Contour]>,
38    pair: Option<(Arc<Pair>, bool)>,
39}
40
41#[derive(Clone, Debug)]
42struct Pair {
43    contours: Vec<MatchedContour>,
44}
45
46#[derive(Clone, Debug)]
47struct MatchedContour {
48    source: Vec<[Point; 4]>,
49    target: Vec<[Point; 4]>,
50    closed: bool,
51}
52
53#[derive(Clone, Copy, Debug)]
54struct Measure {
55    area: f64,
56    rank: f64,
57    length: f64,
58}
59
60#[derive(Clone, Debug)]
61struct Contour {
62    path: Path2D,
63    closed: bool,
64}
65
66fn invalid(field: &str, message: &str) -> ValidationError {
67    ValidationError::invalid("morph_path", field, message)
68}
69
70fn limit(message: &str) -> ValidationError {
71    ValidationError::limit("morph_path", "geometry", message)
72}
73
74fn finish(
75    contours: &mut Vec<Contour>,
76    current: &mut Option<Path2D>,
77    closed: bool,
78) -> Result<(), ValidationError> {
79    if let Some(path) = current.take() {
80        if contours.len() >= MAX_CONTOURS {
81            return Err(limit("Morph paths admit at most 64 contours"));
82        }
83        contours.push(Contour { path, closed });
84    }
85    Ok(())
86}
87
88impl MorphPath {
89    /// Captures a path and promotes line/quadratic segments to cubic controls.
90    /// At most 64 contours are accepted; all existing path limits still apply.
91    pub fn new(path: Path2D) -> Result<Self, ValidationError> {
92        let mut contours = Vec::new();
93        let mut current = None;
94        let mut position = Point::ORIGIN;
95        let mut start = position;
96        for verb in path.verbs() {
97            if let PathVerb::MoveTo(value) = *verb {
98                finish(&mut contours, &mut current, false)?;
99                let mut contour = Path2D::new();
100                contour.try_move_to(value)?;
101                current = Some(contour);
102                position = value;
103                start = value;
104                continue;
105            }
106            if matches!(verb, PathVerb::Close) && current.is_none() {
107                continue;
108            }
109            if current.is_none() {
110                let mut contour = Path2D::new();
111                contour.try_move_to(position)?;
112                start = position;
113                current = Some(contour);
114            }
115            let contour = current.as_mut().expect("active morph contour");
116            match *verb {
117                PathVerb::LineTo(end) => {
118                    contour.try_cubic_to(
119                        position.interpolate(&end, 1.0 / 3.0),
120                        position.interpolate(&end, 2.0 / 3.0),
121                        end,
122                    )?;
123                    position = end;
124                }
125                PathVerb::QuadraticTo(control, end) => {
126                    let cubic = kurbo::QuadBez::new(
127                        (position.x, position.y),
128                        (control.x, control.y),
129                        (end.x, end.y),
130                    )
131                    .raise();
132                    contour.try_cubic_to(
133                        Point::new(cubic.p1.x, cubic.p1.y),
134                        Point::new(cubic.p2.x, cubic.p2.y),
135                        end,
136                    )?;
137                    position = end;
138                }
139                PathVerb::CubicTo(first, second, end) => {
140                    contour.try_cubic_to(first, second, end)?;
141                    position = end;
142                }
143                PathVerb::Close => {
144                    if position != start {
145                        contour.try_cubic_to(
146                            position.interpolate(&start, 1.0 / 3.0),
147                            position.interpolate(&start, 2.0 / 3.0),
148                            start,
149                        )?;
150                    }
151                    contour.try_close()?;
152                    position = start;
153                    finish(&mut contours, &mut current, true)?;
154                }
155                PathVerb::MoveTo(_) => unreachable!("move handled above"),
156            }
157        }
158        finish(&mut contours, &mut current, false)?;
159        Ok(Self {
160            path,
161            contours: contours.into(),
162            pair: None,
163        })
164    }
165
166    /// Prepares a pair once for repeated Tween sampling, using 32 uniform
167    /// arc-length landmarks plus both paths' existing curve boundaries.
168    pub fn pair(source: Path2D, target: Path2D) -> Result<(Self, Self), ValidationError> {
169        Self::pair_with_samples(source, target, DEFAULT_SAMPLES)
170    }
171
172    /// Prepares 4..=256 arc-length landmarks per contour. Output remains capped
173    /// by MAX_PATH_VERBS, with at most 64 matched contours and bounded alignment.
174    pub fn pair_with_samples(
175        source: Path2D,
176        target: Path2D,
177        samples: usize,
178    ) -> Result<(Self, Self), ValidationError> {
179        if !(4..=MAX_SAMPLES).contains(&samples) {
180            return Err(invalid("samples", "Morph sample count must be in 4..=256"));
181        }
182        let mut source = Self::new(source)?;
183        let mut target = Self::new(target)?;
184        let pair = Arc::new(Pair::new(&source, &target, samples)?);
185        source.pair = Some((pair.clone(), false));
186        target.pair = Some((pair, true));
187        Ok((source, target))
188    }
189
190    /// Checked, stateless interpolation. Finite overshoot is allowed; exact
191    /// progress 0/1 returns the original verb snapshots. Unprepared values
192    /// compute correspondence on demand; prefer pair for repeated sampling.
193    pub fn try_interpolate(&self, target: &Self, progress: f64) -> Result<Self, ValidationError> {
194        if !progress.is_finite() {
195            return Err(invalid("progress", "Morph progress must be finite"));
196        }
197        if progress == 0.0 {
198            return Ok(self.clone());
199        }
200        if progress == 1.0 {
201            return Ok(target.clone());
202        }
203        if self.path == target.path {
204            return Ok(self.clone());
205        }
206        let prepared;
207        let (pair, reversed) = match (&self.pair, &target.pair) {
208            (Some((first, side)), Some((second, target_side)))
209                if Arc::ptr_eq(first, second) && side != target_side =>
210            {
211                (first.as_ref(), *side)
212            }
213            _ => {
214                prepared = Pair::new(self, target, DEFAULT_SAMPLES)?;
215                (&prepared, false)
216            }
217        };
218        let mut path = Path2D::new();
219        for contour in &pair.contours {
220            let (source, target) = if reversed {
221                (&contour.target, &contour.source)
222            } else {
223                (&contour.source, &contour.target)
224            };
225            for (index, (first, second)) in source.iter().zip(target).enumerate() {
226                let points: [Point; 4] = std::array::from_fn(|control| {
227                    if first[control] == second[control] {
228                        first[control]
229                    } else {
230                        first[control].interpolate(&second[control], progress)
231                    }
232                });
233                if index == 0 {
234                    path.try_move_to(points[0])?;
235                }
236                path.try_cubic_to(points[1], points[2], points[3])?;
237            }
238            if contour.closed {
239                path.try_close()?;
240            }
241        }
242        Self::new(path)
243    }
244}
245
246impl PartialEq for MorphPath {
247    fn eq(&self, other: &Self) -> bool {
248        self.path == other.path
249    }
250}
251
252impl Interpolate for MorphPath {
253    fn interpolate(&self, target: &Self, progress: f64) -> Self {
254        self.try_interpolate(target, progress)
255            .expect("MorphPath interpolation failed")
256    }
257}
258
259fn cubics(path: &Path2D) -> Vec<[Point; 4]> {
260    let mut position = Point::ORIGIN;
261    let mut result = Vec::new();
262    for verb in path.verbs() {
263        match *verb {
264            PathVerb::MoveTo(value) => position = value,
265            PathVerb::CubicTo(first, second, end) => {
266                result.push([position, first, second, end]);
267                position = end;
268            }
269            _ => {}
270        }
271    }
272    result
273}
274
275fn measure(contour: &Contour) -> Result<Measure, ValidationError> {
276    let length = contour.path.try_length()?;
277    let bounds = contour.path.bounding_box();
278    let scale = bounds.width().max(bounds.height());
279    if !contour.closed || scale == 0.0 {
280        return Ok(Measure {
281            area: 0.0,
282            rank: f64::NEG_INFINITY,
283            length,
284        });
285    }
286    let center = bounds.center();
287    let mut area = 0.0;
288    for curve in cubics(&contour.path) {
289        let controls = curve.map(|point| (point - center) / scale);
290        let controls = controls.map(|point| kurbo::Point::new(point.x, point.y));
291        area +=
292            kurbo::CubicBez::new(controls[0], controls[1], controls[2], controls[3]).signed_area();
293    }
294    if !area.is_finite() {
295        return Err(invalid("area", "Morph contour area must remain finite"));
296    }
297    let rank = if area == 0.0 {
298        f64::NEG_INFINITY
299    } else {
300        libm::log(area.abs()) + 2.0 * libm::log(scale)
301    };
302    Ok(Measure { area, rank, length })
303}
304
305fn reverse(contour: &Contour) -> Result<Path2D, ValidationError> {
306    let curves = cubics(&contour.path);
307    let mut path = Path2D::new();
308    let first = curves
309        .last()
310        .map(|curve| curve[3])
311        .unwrap_or(contour.path.try_point_at(0.0)?.0);
312    path.try_move_to(first)?;
313    for curve in curves.iter().rev() {
314        path.try_cubic_to(curve[2], curve[1], curve[0])?;
315    }
316    if contour.closed {
317        path.try_close()?;
318    }
319    Ok(path)
320}
321
322fn rotate(path: &Path2D, phase: f64) -> Result<Path2D, ValidationError> {
323    if phase == 0.0 {
324        return Ok(path.clone());
325    }
326    let mut output = Path2D::new();
327    for (index, piece) in [path.try_trim(phase, 1.0)?, path.try_trim(0.0, phase)?]
328        .iter()
329        .enumerate()
330    {
331        for verb in piece.verbs() {
332            match *verb {
333                PathVerb::MoveTo(value) if index == 0 => output.try_move_to(value)?,
334                PathVerb::CubicTo(first, second, end) => output.try_cubic_to(first, second, end)?,
335                PathVerb::MoveTo(_) | PathVerb::Close => {}
336                _ => {
337                    return Err(invalid(
338                        "curve",
339                        "Morph normalization requires cubic contours",
340                    ));
341                }
342            }
343        }
344    }
345    output.try_close()?;
346    Ok(output)
347}
348
349fn phase(
350    source: &Path2D,
351    target: &Path2D,
352    samples: usize,
353    work: &mut usize,
354) -> Result<f64, ValidationError> {
355    *work += samples * samples;
356    if *work > MAX_ALIGNMENT_WORK {
357        return Err(limit("Morph alignment work allowance exhausted"));
358    }
359    let first = (0..samples)
360        .map(|index| {
361            source
362                .try_point_at(index as f64 / samples as f64)
363                .map(|value| value.0)
364        })
365        .collect::<Result<Vec<_>, _>>()?;
366    let second = (0..samples)
367        .map(|index| {
368            target
369                .try_point_at(index as f64 / samples as f64)
370                .map(|value| value.0)
371        })
372        .collect::<Result<Vec<_>, _>>()?;
373    let bounds = source.bounding_box().union(target.bounding_box());
374    let scale = bounds.width().max(bounds.height());
375    if !scale.is_finite() {
376        return Err(invalid(
377            "bounds",
378            "Combined morph extent must remain finite",
379        ));
380    }
381    if scale == 0.0 {
382        return Ok(0.0);
383    }
384    let mut best = (f64::INFINITY, 0);
385    for shift in 0..samples {
386        let mut cost = 0.0;
387        for index in 0..samples {
388            let delta = (first[index] - second[(index + shift) % samples]) / scale;
389            cost += delta.dot(delta);
390        }
391        if cost < best.0 {
392            best = (cost, shift);
393        }
394    }
395    Ok(best.1 as f64 / samples as f64)
396}
397
398impl Pair {
399    fn new(
400        source: &MorphPath,
401        target: &MorphPath,
402        samples: usize,
403    ) -> Result<Self, ValidationError> {
404        let mut minimum_verbs = 0;
405        let mut count = 0;
406        for closed in [true, false] {
407            let matched = source
408                .contours
409                .iter()
410                .filter(|contour| contour.closed == closed)
411                .count()
412                .max(
413                    target
414                        .contours
415                        .iter()
416                        .filter(|contour| contour.closed == closed)
417                        .count(),
418                );
419            minimum_verbs += matched * (samples + 1 + usize::from(closed));
420            count += matched;
421        }
422        if count > MAX_CONTOURS || minimum_verbs > MAX_PATH_VERBS {
423            return Err(limit(
424                "Requested morph topology exceeds the contour or verb allowance",
425            ));
426        }
427        let source_measures = source
428            .contours
429            .iter()
430            .map(measure)
431            .collect::<Result<Vec<_>, _>>()?;
432        let target_measures = target
433            .contours
434            .iter()
435            .map(measure)
436            .collect::<Result<Vec<_>, _>>()?;
437        let largest = |measures: &[Measure]| {
438            measures
439                .iter()
440                .max_by(|first, second| first.rank.total_cmp(&second.rank))
441                .map_or(0.0, |value| value.area)
442        };
443        let source_area = largest(&source_measures);
444        let target_area = largest(&target_measures);
445        let reverse_target = source_area != 0.0
446            && target_area != 0.0
447            && source_area.is_sign_positive() != target_area.is_sign_positive();
448        let mut contours = Vec::new();
449        let mut work = 0;
450        let mut verbs = 0;
451        for closed in [true, false] {
452            let sorted = |path: &MorphPath, measures: &[Measure]| {
453                let mut indices = path
454                    .contours
455                    .iter()
456                    .enumerate()
457                    .filter(|(_, contour)| contour.closed == closed)
458                    .map(|(index, _)| index)
459                    .collect::<Vec<_>>();
460                indices.sort_by(|first, second| {
461                    measures[*second]
462                        .rank
463                        .total_cmp(&measures[*first].rank)
464                        .then_with(|| measures[*second].length.total_cmp(&measures[*first].length))
465                        .then_with(|| first.cmp(second))
466                });
467                indices
468            };
469            let source_indices = sorted(source, &source_measures);
470            let target_indices = sorted(target, &target_measures);
471            for index in 0..source_indices.len().max(target_indices.len()) {
472                if contours.len() >= MAX_CONTOURS {
473                    return Err(limit("Matched topology exceeds 64 contours"));
474                }
475                let first = source_indices
476                    .get(index)
477                    .map(|index| &source.contours[*index]);
478                let second = target_indices
479                    .get(index)
480                    .map(|index| &target.contours[*index]);
481                let collapse =
482                    |owner: &MorphPath, fallback: &MorphPath| -> Result<Path2D, ValidationError> {
483                        let mut path = Path2D::new();
484                        let bounds = if owner.path.verbs().is_empty() {
485                            fallback.path.bounding_box()
486                        } else {
487                            owner.path.bounding_box()
488                        };
489                        path.try_move_to(bounds.center())?;
490                        Ok(path)
491                    };
492                let first_path = match first {
493                    Some(contour) => contour.path.clone(),
494                    None => collapse(source, target)?,
495                };
496                let mut second_path = match second {
497                    Some(contour) if reverse_target => reverse(contour)?,
498                    Some(contour) => contour.path.clone(),
499                    None => collapse(target, source)?,
500                };
501                if closed {
502                    second_path = rotate(
503                        &second_path,
504                        phase(&first_path, &second_path, samples, &mut work)?,
505                    )?;
506                }
507                let mut knots = first_path.cubic_breakpoints()?;
508                knots.extend(second_path.cubic_breakpoints()?);
509                knots.extend((0..=samples).map(|index| index as f64 / samples as f64));
510                knots.sort_by(f64::total_cmp);
511                knots.dedup_by(|first, second| (*first - *second).abs() <= 1e-12);
512                if let Some(last) = knots.last_mut() {
513                    *last = 1.0;
514                }
515                verbs += knots.len() + usize::from(closed);
516                if verbs > MAX_PATH_VERBS {
517                    return Err(limit("Resampled morph exceeds the verb allowance"));
518                }
519                let mut first_segments = first_path.cubic_intervals(&knots)?;
520                let mut second_segments = second_path.cubic_intervals(&knots)?;
521                for segments in [&mut first_segments, &mut second_segments] {
522                    for segment in 1..segments.len() {
523                        segments[segment][0] = segments[segment - 1][3];
524                    }
525                    if closed
526                        && let Some(first) = segments.first().map(|segment| segment[0])
527                        && let Some(last) = segments.last_mut()
528                    {
529                        last[3] = first;
530                    }
531                }
532                contours.push(MatchedContour {
533                    source: first_segments,
534                    target: second_segments,
535                    closed,
536                });
537            }
538        }
539        Ok(Self { contours })
540    }
541}
542
543impl Shape for MorphPath {
544    fn path(&self) -> Result<Path2D, ValidationError> {
545        Ok(self.path.clone())
546    }
547
548    fn bounding_box(&self) -> Result<Rect, ValidationError> {
549        Ok(self.path.bounding_box())
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::ValidationErrorKind;
557
558    #[test]
559    fn exact_endpoints_and_nonuniform_curve_segmentation_preserve_geometry() {
560        let source = Path2D::parse_svg("M0 0Q4 8 8 0").unwrap();
561        let target = Path2D::parse_svg("M0 0Q2 4 4 4Q6 4 8 0").unwrap();
562        let (first, second) = MorphPath::pair(source.clone(), target.clone()).unwrap();
563        assert_eq!(first.interpolate(&second, 0.0).path().unwrap(), source);
564        assert_eq!(first.interpolate(&second, 1.0).path().unwrap(), target);
565        for progress in [0.01, 0.25, 0.5, 0.99] {
566            let result = first.try_interpolate(&second, progress).unwrap();
567            assert!((result.path().unwrap().length() - source.length()).abs() < 1e-8);
568            for sample in [0.0, 0.25, 0.5, 0.75, 1.0] {
569                assert!(
570                    result
571                        .path()
572                        .unwrap()
573                        .point_at(sample)
574                        .0
575                        .distance(source.point_at(sample).0)
576                        < 1e-8
577                );
578            }
579        }
580        let line = Path2D::parse_svg("M0 0H8").unwrap();
581        let curve = Path2D::parse_svg("M0 4Q4 12 8 4").unwrap();
582        let (first, second) = MorphPath::pair(line, curve).unwrap();
583        let middle = first.interpolate(&second, 0.5).path().unwrap();
584        assert!(middle.point_at(0.5).0.distance(Point::new(4.0, 4.0)) < 1e-8);
585        assert_eq!(middle.point_at(0.0).0, Point::new(0.0, 2.0));
586        assert_eq!(middle.point_at(1.0).0, Point::new(8.0, 2.0));
587    }
588
589    #[test]
590    fn closed_phase_alignment_and_area_pairing_are_not_input_order_zip() {
591        let source = Path2D::parse_svg("M2 2H10V10H2Z").unwrap();
592        let target = Path2D::parse_svg("M10 10H2V2H10Z").unwrap();
593        let (first, second) = MorphPath::pair(source, target).unwrap();
594        let middle = first.interpolate(&second, 0.5);
595        assert_eq!(
596            middle.bounding_box().unwrap(),
597            Rect::new(2.0, 2.0, 10.0, 10.0)
598        );
599        let source = Path2D::parse_svg("M99 -1h2v2h-2Z M-3 -3h6v6h-6Z").unwrap();
600        let target = Path2D::parse_svg("M16 -4h8v8h-8Z M199 -1h2v2h-2Z").unwrap();
601        let (first, second) = MorphPath::pair(source, target).unwrap();
602        let middle = first.interpolate(&second, 0.5);
603        assert!((middle.contours[0].path.bounding_box().center().x - 10.0).abs() < 1e-8);
604        assert!((middle.contours[1].path.bounding_box().center().x - 150.0).abs() < 1e-8);
605    }
606
607    #[test]
608    fn missing_contours_and_holes_grow_from_points_with_winding_preserved() {
609        let source = Path2D::from_rect(Rect::new(0.0, 0.0, 16.0, 16.0)).unwrap();
610        let target = Path2D::parse_svg("M0 0H16V16H0Z M6 6V10H10V6Z").unwrap();
611        let (first, second) = MorphPath::pair(source, target).unwrap();
612        let middle = first.interpolate(&second, 0.5);
613        assert_eq!(middle.contours.len(), 2);
614        assert_eq!(
615            middle.contours[1].path.bounding_box(),
616            Rect::new(7.0, 7.0, 9.0, 9.0)
617        );
618        let (empty, filled) = MorphPath::pair(
619            Path2D::new(),
620            crate::Circle::new(Point::new(8.0, 8.0), 4.0)
621                .unwrap()
622                .path()
623                .unwrap(),
624        )
625        .unwrap();
626        assert!(empty.path().unwrap().verbs().is_empty());
627        assert!(
628            (empty
629                .interpolate(&filled, 0.5)
630                .bounding_box()
631                .unwrap()
632                .width()
633                - 4.0)
634                .abs()
635                < 1e-8
636        );
637        assert!(
638            filled
639                .interpolate(&empty, 1.0)
640                .path()
641                .unwrap()
642                .verbs()
643                .is_empty()
644        );
645    }
646
647    #[test]
648    fn disappearing_and_offset_holes_follow_the_path_coordinate_frame() {
649        let source = Path2D::parse_svg("M0 0H16V16H0Z M6 6V10H10V6Z").unwrap();
650        let target = Path2D::parse_svg("M16 0H32V16H16Z").unwrap();
651        let (first, second) = MorphPath::pair(source, target).unwrap();
652        let middle = first.interpolate(&second, 0.5);
653        let outer = middle.contours[0].path.bounding_box();
654        let hole = middle.contours[1].path.bounding_box();
655        assert_eq!(hole.center(), Point::new(16.0, 8.0));
656        assert!(
657            hole.left >= outer.left
658                && hole.right <= outer.right
659                && hole.top >= outer.top
660                && hole.bottom <= outer.bottom
661        );
662        let target = Path2D::parse_svg("M0 0H16V16H0Z M2 2V4H4V2Z").unwrap();
663        let (first, second) = MorphPath::pair(Path2D::new(), target).unwrap();
664        let middle = first.interpolate(&second, 0.5);
665        let outer = middle.contours[0].path.bounding_box();
666        let hole = middle.contours[1].path.bounding_box();
667        assert!(
668            hole.left >= outer.left
669                && hole.right <= outer.right
670                && hole.top >= outer.top
671                && hole.bottom <= outer.bottom
672        );
673    }
674
675    #[test]
676    fn prepared_morphs_support_tweens_reverse_queries_and_checked_failures() {
677        use crate::{Duration, Signal, Time, Tween};
678        let source = Path2D::parse_svg("M1 1H5V5H1Z").unwrap();
679        let target = Path2D::parse_svg("M3 3H9V9H3Z").unwrap();
680        let (first, second) = MorphPath::pair(source.clone(), target.clone()).unwrap();
681        let animation = Tween::new(first.clone(), second.clone(), Duration::from_ticks(100));
682        let expected = animation.sample(Time::from_ticks(50));
683        assert_eq!(
684            MorphPath::new(source.clone())
685                .unwrap()
686                .try_interpolate(&MorphPath::new(target.clone()).unwrap(), 0.5)
687                .unwrap(),
688            expected
689        );
690        std::thread::scope(|scope| {
691            let handles = (0..3)
692                .map(|_| scope.spawn(|| first.interpolate(&second, 0.5)))
693                .collect::<Vec<_>>();
694            for handle in handles {
695                assert_eq!(handle.join().unwrap(), expected);
696            }
697        });
698        for ticks in [100, 25, 0, 75, 50] {
699            let _result = animation.sample(Time::from_ticks(ticks));
700        }
701        assert_eq!(animation.sample(Time::from_ticks(50)), expected);
702        assert_eq!(
703            first.interpolate(&second, 0.25),
704            second.interpolate(&first, 0.75)
705        );
706        assert!(first.try_interpolate(&second, f64::NAN).is_err());
707        assert!(first.try_interpolate(&second, f64::MAX).is_err());
708        let (stationary, growing) = MorphPath::pair(
709            Path2D::parse_svg("M1 0H2").unwrap(),
710            Path2D::parse_svg("M1 0H3").unwrap(),
711        )
712        .unwrap();
713        assert_eq!(
714            stationary
715                .try_interpolate(&growing, 1e16)
716                .unwrap()
717                .path()
718                .unwrap()
719                .verbs()[0],
720            PathVerb::MoveTo(Point::new(1.0, 0.0))
721        );
722        assert_eq!(first.path().unwrap(), source);
723        assert_eq!(second.path().unwrap(), target);
724        assert!(MorphPath::pair_with_samples(source, target, 257).is_err());
725        let mut many = Path2D::new();
726        for index in 0..65 {
727            many.try_move_to(Point::new(index as f64, 0.0)).unwrap();
728        }
729        assert_eq!(
730            MorphPath::new(many).unwrap_err().kind,
731            ValidationErrorKind::StructuralLimit
732        );
733        let mut bounded = Path2D::new();
734        for index in 0..64 {
735            bounded.try_move_to(Point::new(index as f64, 0.0)).unwrap();
736            bounded.try_close().unwrap();
737        }
738        assert_eq!(
739            MorphPath::pair_with_samples(bounded.clone(), bounded, 256)
740                .unwrap_err()
741                .kind,
742            ValidationErrorKind::StructuralLimit
743        );
744    }
745
746    #[test]
747    fn cubic_promotion_preserves_source_geometry_and_contour_boundaries() {
748        let source = Path2D::parse_svg("M0 0L2 0Q3 2 4 0Z L1 1 M10 10").unwrap();
749        let retained = source.clone();
750        let morph = MorphPath::new(source).unwrap();
751        assert_eq!(morph.path().unwrap(), retained);
752        assert_eq!(morph.contours.len(), 3);
753        assert!(morph.contours[0].closed);
754        assert!(!morph.contours[1].closed);
755        assert!(
756            morph
757                .contours
758                .iter()
759                .all(|contour| contour.path.verbs().iter().all(|verb| matches!(
760                    verb,
761                    PathVerb::MoveTo(_) | PathVerb::CubicTo(..) | PathVerb::Close
762                )))
763        );
764        let quadratic = Path2D::parse_svg("M0 0Q1 1 2 0").unwrap();
765        let promoted = MorphPath::new(quadratic.clone()).unwrap();
766        assert!((promoted.contours[0].path.length() - quadratic.length()).abs() < 1e-10);
767        for progress in [0.0, 0.25, 0.5, 0.75, 1.0] {
768            assert!(
769                promoted.contours[0]
770                    .path
771                    .point_at(progress)
772                    .0
773                    .distance(quadratic.point_at(progress).0)
774                    < 1e-9
775            );
776        }
777        assert!(MorphPath::new(Path2D::new()).unwrap().contours.is_empty());
778    }
779}