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