1use 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#[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 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 pub fn pair(source: Path2D, target: Path2D) -> Result<(Self, Self), ValidationError> {
170 Self::pair_with_samples(source, target, DEFAULT_SAMPLES)
171 }
172
173 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 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}