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