Skip to main content

frust_material/shapes/
morph.rs

1//! Feature-matched morphing between two [`RoundedPolygon`]s: the arc-length
2//! measurer, the corner-to-corner feature mapping, and [`Morph`] itself.
3//!
4//! Ported from `material_new_shapes` 1.0.0's `lib/src/shapes/` (`morph.dart`,
5//! `feature_mapping.dart`, `polygon_measure.dart`, `float_mapping.dart`) — the
6//! half [`super::rounded_polygon`] deliberately left alone. Attribution and the
7//! polygon-side name map live in the [parent module's docs](super).
8//!
9//! # How a morph is built
10//!
11//! Interpolating two outlines needs a curve-to-curve correspondence, and two
12//! polygons rarely have the same number of curves in the same places. The
13//! pipeline that produces one, in order:
14//!
15//! 1. **Measure.** Every cubic of each polygon gets an *outline progress*
16//!    range: where it starts and ends as a fraction of the whole perimeter
17//!    (arc length, approximated by [`MEASURE_SEGMENTS`] chords). Zero-length
18//!    cubics — every unrounded corner is one — drop out here.
19//! 2. **Map features.** Each corner ([`Feature::is_corner`]) carries the
20//!    progress of its midpoint. Corners of the two shapes are paired greedily
21//!    by proximity, closest first, never pairing a convex corner with a
22//!    concave one and never introducing a crossing. The pairs define a
23//!    [`DoubleMapper`]: a piecewise-linear, wrap-around bijection from
24//!    progress on the start shape to progress on the end shape.
25//! 3. **Cut and shift.** Progress `0` on the start shape maps to some progress
26//!    on the end shape; the end shape is re-cut to begin there, so both
27//!    outlines can be walked together from `0`.
28//! 4. **Match.** Walking both, whichever curve ends first sets the boundary;
29//!    the curve that overruns is split there. The result is a list of
30//!    (start-curve, end-curve) pairs covering both outlines exactly.
31//!
32//! [`Morph::path`] then interpolates each pair element-wise on the flat
33//! eight-float [`Cubic`] layout. Steps 1–4 all happen in [`Morph::new`], which
34//! is why a morph is built once and kept, not rebuilt per frame.
35//!
36//! ## Dart → Rust name map
37//!
38//! | Upstream (Dart) | Here (Rust) | Note |
39//! |---|---|---|
40//! | `Morph` | [`Morph`] | `_start`/`_end`/`_morphMatch` → `start`/`end`/`pairs` |
41//! | `Morph.asCubics` | [`Morph::as_cubics`] | |
42//! | `MorphToPathExtension.toPath` | [`Morph::path`] | emits a closed [`kurbo::BezPath`]; the `startAngle`/`repeatPath` options are not ported (see deviations) |
43//! | `Morph.calculateBounds(approximate:)` | [`Morph::bounds`] / [`Morph::exact_bounds`] | split in two rather than taking a flag, as the polygon's are |
44//! | `Morph.calculateMaxBounds` | [`Morph::max_bounds`] | |
45//! | `Measurer` / `LengthMeasurer` | [`measure_cubic`] + [`find_cubic_cut_point`] | one implementation, so the interface is collapsed (see deviations) |
46//! | `MeasuredPolygon` / `MeasuredCubic` | [`MeasuredPolygon`] / [`MeasuredCubic`] | |
47//! | `MeasuredPolygon.cutAndShift` | [`MeasuredPolygon::cut_and_shift`] | consumes and returns the polygon |
48//! | `ProgressableFeature` | [`ProgressableFeature`] | holds the feature's kind and representative point rather than the feature itself |
49//! | `featureMapper` / `doMapping` | [`feature_mapper`] / [`do_mapping`] | |
50//! | `featureDistSquared` / `featureRepresentativePoint` | [`feature_dist_squared`] / [`feature_representative_point`] | |
51//! | `DoubleMapper` / `linearMap` | [`DoubleMapper`] / [`linear_map`] | |
52//! | `progressInRange` / `progressDistance` / `validateProgress` | [`progress_in_range`] / [`progress_distance`] / [`validate_progress`] | |
53//! | `positiveModulo` | [`positive_modulo`] | |
54//! | `angleEpsilon` | [`ANGLE_EPSILON`] | |
55//!
56//! ## Deliberate deviations
57//!
58//! Everything else is a structure-preserving port — same pipeline, same
59//! formulas, same constants. Where this port does **not** mirror upstream:
60//!
61//! 1. **The `Measurer` interface is collapsed into its one implementation.**
62//!    Upstream declares an interface and passes a `LengthMeasurer` instance
63//!    everywhere; nothing else ever implements it and `Morph` hardcodes that
64//!    choice, so the two methods are free functions here.
65//! 2. **`Option` instead of a sentinel distance.** `featureDistSquared`
66//!    returns `double.maxFinite` to mean "never map these two"; here it is
67//!    [`None`].
68//! 3. **A zero-length chord at a zero remaining measure cuts at that point,
69//!    rather than dividing `0 / 0`.** Upstream's cut-point search evaluates
70//!    `remainder / segment` with both zero when a cut lands exactly on a
71//!    curve's start, yielding a `NaN` parameter that propagates into the split
72//!    curve; the whole remaining measure is reached at that point, so the
73//!    ratio is `1` and the cut is at the segment's own progress.
74//! 4. **Contract violations panic** (with a documented `# Panics` section)
75//!    rather than returning a typed error, exactly as the polygon side does —
76//!    upstream throws `ArgumentError`/`StateError` at each of these points.
77//!    Structural invariants upstream asserts (debug-only in Dart) are
78//!    `debug_assert!`s here.
79//! 5. **Ties in the feature-pair ordering resolve in generation order.** The
80//!    candidate pairs are stably sorted by distance, so equidistant pairs keep
81//!    their `(start feature, end feature)` enumeration order; Dart's
82//!    `List.sort` is unstable, so a tie can resolve either way there. Only
83//!    exactly-tied pairs are affected, and only in which of them is mapped
84//!    first.
85//! 6. **A morph's bounds inherit the polygon side's `±∞` seeding fix**
86//!    (the parent module's deviation 3), so [`Morph::bounds`] can be tighter
87//!    than upstream's for a shape lying in negative coordinates.
88//! 7. **`toPath`'s `startAngle`/`repeatPath`/`closePath` options are not
89//!    ported.** [`Morph::path`] always emits the whole closed outline, like
90//!    [`RoundedPolygon::to_path`]; a caller wanting a rotation applies an
91//!    [`kurbo::Affine`] to the path, and the progressive-stroke cases those
92//!    flags serve have no consumer here.
93
94use kurbo::{BezPath, Point, Rect};
95
96use super::{Cubic, DISTANCE_EPSILON, Feature, FeatureKind, RoundedPolygon};
97
98/// Below this difference in outline progress, two positions on an outline count
99/// as the same one and no curve is cut between them (upstream's
100/// `angleEpsilon`).
101const ANGLE_EPSILON: f64 = 1e-6;
102
103/// How many chords the arc length of one cubic is approximated by.
104///
105/// Upstream's `LengthMeasurer._segments`, with its measured claim: three chords
106/// hold the error under 1.5% of the true arc length for a circular arc, which
107/// is the worst case among the shapes this engine builds. The morph only ever
108/// compares measurements against each other, so a consistent slight
109/// under-measure costs nothing.
110const MEASURE_SEGMENTS: usize = 3;
111
112// ---------------------------------------------------------------------------
113// Progress arithmetic (float_mapping.dart, utils.dart)
114// ---------------------------------------------------------------------------
115
116/// `value` modulo `modulus`, always in `0..modulus`.
117///
118/// [`f64::rem_euclid`] can return the modulus itself for a tiny negative input
119/// (`-1e-18` rounds up to a bare `1.0`), which the `[0, 1)` progress contract
120/// forbids; the extra fold matches what Dart's own double `%` yields there.
121fn positive_modulo(value: f64, modulus: f64) -> f64 {
122    let remainder = value.rem_euclid(modulus);
123    if remainder >= modulus { 0.0 } else { remainder }
124}
125
126/// The distance between two progress values, the short way round: `0.99` and
127/// `0.0` are `0.01` apart.
128fn progress_distance(p1: f64, p2: f64) -> f64 {
129    let diff = (p1 - p2).abs();
130    diff.min(1.0 - diff)
131}
132
133/// Whether `progress` lies in the range running from `from` to `to`.
134///
135/// Progress wraps, so a range whose end is below its start is the one crossing
136/// `0`: `0.7..0.2` holds both `0.8` and `0.1`, but not `0.5`.
137fn progress_in_range(progress: f64, from: f64, to: f64) -> bool {
138    if to >= from {
139        progress >= from && progress <= to
140    } else {
141        progress >= from || progress <= to
142    }
143}
144
145/// Interpolates `x` through the piecewise-linear, wrapping map that sends each
146/// `x_values[i]` to `y_values[i]`.
147///
148/// # Panics
149///
150/// If `x` falls in no segment, which cannot happen for a validated mapping
151/// (the segments cover the whole circle). Debug-asserts `x` is in `0..=1`.
152fn linear_map(x_values: &[f64], y_values: &[f64], x: f64) -> f64 {
153    debug_assert!((0.0..=1.0).contains(&x), "progress out of range: {x}");
154
155    let start = (0..x_values.len())
156        .find(|&i| progress_in_range(x, x_values[i], x_values[(i + 1) % x_values.len()]))
157        .expect("a validated mapping covers every progress value");
158    let end = (start + 1) % x_values.len();
159
160    let segment_size_x = positive_modulo(x_values[end] - x_values[start], 1.0);
161    let segment_size_y = positive_modulo(y_values[end] - y_values[start], 1.0);
162    // A segment too short to divide by: take its midpoint rather than amplify
163    // the round-off.
164    let position_in_segment = if segment_size_x < 0.001 {
165        0.5
166    } else {
167        positive_modulo(x - x_values[start], 1.0) / segment_size_x
168    };
169
170    positive_modulo(y_values[start] + segment_size_y * position_in_segment, 1.0)
171}
172
173/// A bijection between two `0..1` progress spaces, given by a finite list of
174/// corresponding values and extended by linear interpolation with wrap-around.
175///
176/// Given the pairs `(0.2, 0.5)` and `(0.4, 0.6)`, source `0.3` — halfway along
177/// the source interval — maps to `0.55`, halfway along the target one.
178#[derive(Clone, Debug)]
179struct DoubleMapper {
180    source: Vec<f64>,
181    target: Vec<f64>,
182}
183
184impl DoubleMapper {
185    /// The mapper defined by `mappings`, each pair a (source, target) value.
186    ///
187    /// # Panics
188    ///
189    /// If either side is empty, holds a value outside `0..1`, repeats a value,
190    /// or wraps more than once (see [`validate_progress`]).
191    fn new(mappings: &[(f64, f64)]) -> DoubleMapper {
192        let source: Vec<f64> = mappings.iter().map(|m| m.0).collect();
193        let target: Vec<f64> = mappings.iter().map(|m| m.1).collect();
194        validate_progress(&source, "source");
195        validate_progress(&target, "target");
196        DoubleMapper { source, target }
197    }
198
199    /// The target-space value corresponding to source-space `x`.
200    fn map(&self, x: f64) -> f64 {
201        linear_map(&self.source, &self.target, x)
202    }
203
204    /// The source-space value corresponding to target-space `x`.
205    fn map_back(&self, x: f64) -> f64 {
206        linear_map(&self.target, &self.source, x)
207    }
208}
209
210/// Checks that progress values are in `0..1`, distinct, and monotonically
211/// increasing with at most one wrap around `0`.
212///
213/// # Panics
214///
215/// If any of that fails — a mapping that repeats or double-wraps has no
216/// well-defined inverse.
217fn validate_progress(values: &[f64], side: &str) {
218    assert!(
219        !values.is_empty(),
220        "a {side} mapping needs at least one value"
221    );
222
223    let mut previous = values[values.len() - 1];
224    let mut wraps = 0_u32;
225    for &current in values {
226        assert!(
227            (0.0..1.0).contains(&current),
228            "{side} progress outside 0..1: {values:?}"
229        );
230        assert!(
231            progress_distance(current, previous) > DISTANCE_EPSILON,
232            "{side} progress repeats a value: {values:?}"
233        );
234        if current < previous {
235            wraps += 1;
236            assert!(
237                wraps <= 1,
238                "{side} progress wraps more than once: {values:?}"
239            );
240        }
241        previous = current;
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Measuring (polygon_measure.dart)
247// ---------------------------------------------------------------------------
248
249/// The length of `cubic`, approximated by [`MEASURE_SEGMENTS`] chords.
250fn measure_cubic(cubic: &Cubic) -> f64 {
251    closest_progress_to(cubic, f64::INFINITY).1
252}
253
254/// The parameter `t` at which `cubic` has covered `measure` of its length,
255/// capped at its full length.
256fn find_cubic_cut_point(cubic: &Cubic, measure: f64) -> f64 {
257    closest_progress_to(cubic, measure).0
258}
259
260/// Walks `cubic` in [`MEASURE_SEGMENTS`] chords, returning the parameter at
261/// which `threshold` length is reached and the threshold itself — or, if the
262/// curve is shorter than that, `(1, its length)`.
263///
264/// One walk answers both of the measurer's questions: `f64::INFINITY` is never
265/// reached, so it returns the total length.
266fn closest_progress_to(cubic: &Cubic, threshold: f64) -> (f64, f64) {
267    let segments = MEASURE_SEGMENTS as f64;
268    let mut total = 0.0;
269    let mut remainder = threshold;
270    let mut previous = cubic.anchor0();
271
272    for i in 0..=MEASURE_SEGMENTS {
273        let progress = i as f64 / segments;
274        let point = cubic.point_on_curve(progress);
275        let segment = (point - previous).hypot();
276
277        if segment >= remainder {
278            // A zero-length chord is only ever "reached" with nothing left to
279            // measure, and then the cut is exactly here.
280            let covered = if segment > 0.0 {
281                remainder / segment
282            } else {
283                1.0
284            };
285            return (progress - (1.0 - covered) / segments, threshold);
286        }
287
288        remainder -= segment;
289        total += segment;
290        previous = point;
291    }
292
293    (1.0, total)
294}
295
296/// One cubic of an outline, with the stretch of that outline it covers.
297///
298/// Outline progress is the fraction of the whole perimeter travelled, so two
299/// shapes' curves can be compared by where they sit rather than by index.
300#[derive(Clone, Copy, Debug)]
301struct MeasuredCubic {
302    cubic: Cubic,
303    measured_size: f64,
304    start_outline_progress: f64,
305    end_outline_progress: f64,
306}
307
308impl MeasuredCubic {
309    fn new(cubic: Cubic, start_outline_progress: f64, end_outline_progress: f64) -> MeasuredCubic {
310        debug_assert!(
311            (0.0..=1.0).contains(&start_outline_progress)
312                && (0.0..=1.0).contains(&end_outline_progress)
313                && end_outline_progress >= start_outline_progress,
314            "outline progress range must be ordered and within 0..=1, got \
315             {start_outline_progress}..{end_outline_progress}"
316        );
317        MeasuredCubic {
318            cubic,
319            measured_size: measure_cubic(&cubic),
320            start_outline_progress,
321            end_outline_progress,
322        }
323    }
324
325    /// Splits this curve at an outline progress value, into the part before it
326    /// and the part after.
327    ///
328    /// The cut is clamped into this curve's own range first: round-off further
329    /// up can put it a hair outside, and compounding that error is worse than
330    /// cutting at the very end.
331    ///
332    /// # Panics
333    ///
334    /// If the cut lands outside the curve's parameter range, which a clamped
335    /// cut cannot.
336    fn cut_at_progress(&self, cut_outline_progress: f64) -> (MeasuredCubic, MeasuredCubic) {
337        let bounded =
338            cut_outline_progress.clamp(self.start_outline_progress, self.end_outline_progress);
339        let progress_size = self.end_outline_progress - self.start_outline_progress;
340        let progress_from_start = bounded - self.start_outline_progress;
341
342        // Empty curves are filtered out before any cut, so this cannot divide
343        // by zero.
344        let relative_progress = progress_from_start / progress_size;
345        let t = find_cubic_cut_point(&self.cubic, relative_progress * self.measured_size);
346        assert!(
347            (0.0..=1.0).contains(&t),
348            "a cubic cut point must be in 0..=1, got {t}"
349        );
350
351        let (before, after) = self.cubic.split(t);
352        (
353            MeasuredCubic::new(before, self.start_outline_progress, bounded),
354            MeasuredCubic::new(after, bounded, self.end_outline_progress),
355        )
356    }
357}
358
359/// A polygon's corner, reduced to what the feature mapping needs: where it sits
360/// along the outline, where it sits in space, and which way it indents.
361#[derive(Clone, Copy, Debug)]
362struct ProgressableFeature {
363    progress: f64,
364    representative: Point,
365    kind: FeatureKind,
366}
367
368/// The point standing in for a whole feature when measuring how far apart two
369/// of them are: the midpoint of its first and last anchors.
370fn feature_representative_point(feature: &Feature) -> Point {
371    let cubics = feature.cubics();
372    let first = cubics[0].anchor0();
373    let last = cubics[cubics.len() - 1].anchor1();
374    Point::new((first.x + last.x) / 2.0, (first.y + last.y) / 2.0)
375}
376
377/// How far apart two features are, squared — or [`None`] when they must never
378/// be mapped to each other.
379///
380/// Convex maps only to convex and concave only to concave: pairing a star's
381/// outer point with another star's inner one turns the morph inside out.
382fn feature_dist_squared(f1: &ProgressableFeature, f2: &ProgressableFeature) -> Option<f64> {
383    if f1.kind != f2.kind {
384        return None;
385    }
386    Some((f1.representative - f2.representative).hypot2())
387}
388
389/// A polygon's outline, cut into curves that each know where they sit along it,
390/// plus the progress of every corner.
391#[derive(Clone, Debug)]
392struct MeasuredPolygon {
393    cubics: Vec<MeasuredCubic>,
394    features: Vec<ProgressableFeature>,
395}
396
397impl MeasuredPolygon {
398    /// Measures `polygon`'s outline.
399    ///
400    /// The curves come from the polygon's [`Feature`]s rather than its flat
401    /// cubic list, since a feature's cubics are what a corner's progress is
402    /// measured against; the two describe the same closed outline.
403    ///
404    /// # Panics
405    ///
406    /// If the outline has no measurable length at all (a fully degenerate
407    /// polygon), since every progress value would then be `0/0`.
408    fn measure(polygon: &RoundedPolygon) -> MeasuredPolygon {
409        let mut cubics: Vec<Cubic> = Vec::new();
410        // Each corner is represented by the cubic in the middle of its run —
411        // the inner arc of a rounded corner.
412        let mut corner_cubic_indices: Vec<(usize, &Feature)> = Vec::new();
413
414        for feature in polygon.features() {
415            let representative = feature.cubics().len() / 2;
416            for (i, cubic) in feature.cubics().iter().enumerate() {
417                if feature.is_corner() && i == representative {
418                    corner_cubic_indices.push((cubics.len(), feature));
419                }
420                cubics.push(*cubic);
421            }
422        }
423
424        let mut measures = vec![0.0; cubics.len() + 1];
425        let mut total_measure = 0.0;
426        for (i, cubic) in cubics.iter().enumerate() {
427            let measure = measure_cubic(cubic);
428            debug_assert!(measure >= 0.0, "a measured cubic cannot be negative");
429            total_measure += measure;
430            measures[i + 1] = total_measure;
431        }
432        assert!(
433            total_measure > 0.0,
434            "a polygon with no measurable outline cannot be morphed"
435        );
436
437        let outline_progress: Vec<f64> = measures.iter().map(|m| m / total_measure).collect();
438        let features = corner_cubic_indices
439            .iter()
440            .map(|&(index, feature)| ProgressableFeature {
441                progress: positive_modulo(
442                    (outline_progress[index] + outline_progress[index + 1]) / 2.0,
443                    1.0,
444                ),
445                representative: feature_representative_point(feature),
446                kind: feature.kind(),
447            })
448            .collect();
449
450        MeasuredPolygon::assemble(features, &cubics, &outline_progress)
451    }
452
453    /// Builds the measured outline from curves and the progress boundaries
454    /// between them, dropping the empty ones.
455    ///
456    /// # Panics
457    ///
458    /// If every curve is empty. Debug-asserts that the boundaries run from `0`
459    /// to `1` with one more of them than there are curves.
460    fn assemble(
461        features: Vec<ProgressableFeature>,
462        cubics: &[Cubic],
463        outline_progress: &[f64],
464    ) -> MeasuredPolygon {
465        debug_assert!(
466            outline_progress.len() == cubics.len() + 1,
467            "expected one more outline-progress value than curves"
468        );
469        debug_assert!(
470            outline_progress[0] == 0.0 && outline_progress[outline_progress.len() - 1] == 1.0,
471            "outline progress must run from 0 to 1"
472        );
473
474        let mut measured: Vec<MeasuredCubic> = Vec::with_capacity(cubics.len());
475        let mut start = 0.0;
476        for (i, cubic) in cubics.iter().enumerate() {
477            if outline_progress[i + 1] - outline_progress[i] > DISTANCE_EPSILON {
478                measured.push(MeasuredCubic::new(*cubic, start, outline_progress[i + 1]));
479                // The next curve starts exactly where this one ends, closing
480                // the gaps the dropped curves left.
481                start = outline_progress[i + 1];
482            }
483        }
484
485        // Trailing curves may have been dropped; the outline still has to end
486        // at 1.
487        measured
488            .last_mut()
489            .expect("a measurable outline has at least one non-empty curve")
490            .end_outline_progress = 1.0;
491
492        MeasuredPolygon {
493            cubics: measured,
494            features,
495        }
496    }
497
498    /// The same outline, re-cut to start at `cutting_point`.
499    ///
500    /// Given curves over `[0, 0.2] [0.2, 0.5] [0.5, 1]` and a cut at `0.4`, the
501    /// second curve splits and the result runs `[0, 0.1] [0.1, 0.6] [0.6, 0.8]
502    /// [0.8, 1]` — the same closed outline, walked from a different point.
503    ///
504    /// # Panics
505    ///
506    /// If `cutting_point` is outside `0..=1`, or lands in none of the curves.
507    fn cut_and_shift(self, cutting_point: f64) -> MeasuredPolygon {
508        assert!(
509            (0.0..=1.0).contains(&cutting_point),
510            "a cutting point must be in 0..=1, got {cutting_point}"
511        );
512        if cutting_point < DISTANCE_EPSILON {
513            return self;
514        }
515
516        let target_index = self
517            .cubics
518            .iter()
519            .position(|c| {
520                cutting_point >= c.start_outline_progress && cutting_point <= c.end_outline_progress
521            })
522            .expect("the cutting point lies on the outline");
523        let (before, after) = self.cubics[target_index].cut_at_progress(cutting_point);
524
525        // The part of the target curve after the cut, then everything from
526        // there round to the target again, then the part before the cut.
527        let mut cubics = Vec::with_capacity(self.cubics.len() + 2);
528        cubics.push(after.cubic);
529        for i in 1..self.cubics.len() {
530            cubics.push(self.cubics[(i + target_index) % self.cubics.len()].cubic);
531        }
532        cubics.push(before.cubic);
533
534        // Every boundary moves back by the cut, wrapping; the two ends are the
535        // cut itself.
536        let mut outline_progress = Vec::with_capacity(cubics.len() + 1);
537        for i in 0..=cubics.len() {
538            if i == 0 {
539                outline_progress.push(0.0);
540            } else if i == cubics.len() {
541                outline_progress.push(1.0);
542            } else {
543                let index = (target_index + i - 1) % self.cubics.len();
544                outline_progress.push(positive_modulo(
545                    self.cubics[index].end_outline_progress - cutting_point,
546                    1.0,
547                ));
548            }
549        }
550
551        let features = self
552            .features
553            .iter()
554            .map(|f| ProgressableFeature {
555                progress: positive_modulo(f.progress - cutting_point, 1.0),
556                ..*f
557            })
558            .collect();
559
560        MeasuredPolygon::assemble(features, &cubics, &outline_progress)
561    }
562
563    fn get(&self, index: usize) -> Option<MeasuredCubic> {
564        self.cubics.get(index).copied()
565    }
566}
567
568// ---------------------------------------------------------------------------
569// Feature mapping (feature_mapping.dart)
570// ---------------------------------------------------------------------------
571
572/// The progress mapping between two shapes, built from their corners alone.
573fn feature_mapper(
574    features1: &[ProgressableFeature],
575    features2: &[ProgressableFeature],
576) -> DoubleMapper {
577    let corners = |features: &[ProgressableFeature]| -> Vec<ProgressableFeature> {
578        features
579            .iter()
580            .copied()
581            .filter(|f| f.kind != FeatureKind::Edge)
582            .collect()
583    };
584    DoubleMapper::new(&do_mapping(&corners(features1), &corners(features2)))
585}
586
587/// Pairs the corners of two shapes, returning (start progress, end progress)
588/// pairs sorted by the first element.
589///
590/// Every possible pair is scored by distance and taken in ascending order,
591/// skipping any that would map an already-mapped corner, sit within
592/// [`DISTANCE_EPSILON`] of an existing pair (which would make the mapping
593/// unstable), or cross an existing pair.
594///
595/// # Panics
596///
597/// If two corners of the same shape share an outline progress value.
598fn do_mapping(
599    features1: &[ProgressableFeature],
600    features2: &[ProgressableFeature],
601) -> Vec<(f64, f64)> {
602    let mut candidates: Vec<(f64, usize, usize)> = Vec::new();
603    for (i1, f1) in features1.iter().enumerate() {
604        for (i2, f2) in features2.iter().enumerate() {
605            if let Some(distance) = feature_dist_squared(f1, f2) {
606                candidates.push((distance, i1, i2));
607            }
608        }
609    }
610    candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
611
612    // Nothing to go on: map progress straight through.
613    let Some(&(_, first1, first2)) = candidates.first() else {
614        return vec![(0.0, 0.0), (0.5, 0.5)];
615    };
616    // One pair fixes an offset but not a scale; the opposite point pins it.
617    if candidates.len() == 1 {
618        let (p1, p2) = (features1[first1].progress, features2[first2].progress);
619        return vec![(p1, p2), ((p1 + 0.5) % 1.0, (p2 + 0.5) % 1.0)];
620    }
621
622    let mut helper = MappingHelper::new(features1.len(), features2.len());
623    for &(_, i1, i2) in &candidates {
624        helper.add(features1[i1].progress, features2[i2].progress, i1, i2);
625    }
626    helper.mapping
627}
628
629/// Accumulates the corner pairing, keeping the mapping sorted by start progress
630/// and free of crossings.
631struct MappingHelper {
632    mapping: Vec<(f64, f64)>,
633    used1: Vec<bool>,
634    used2: Vec<bool>,
635}
636
637impl MappingHelper {
638    fn new(len1: usize, len2: usize) -> MappingHelper {
639        MappingHelper {
640            mapping: Vec::new(),
641            used1: vec![false; len1],
642            used2: vec![false; len2],
643        }
644    }
645
646    fn add(&mut self, p1: f64, p2: f64, i1: usize, i2: usize) {
647        // A corner is mapped once.
648        if self.used1[i1] || self.used2[i2] {
649            return;
650        }
651
652        let insertion = match self
653            .mapping
654            .binary_search_by(|probe| probe.0.total_cmp(&p1))
655        {
656            Ok(_) => panic!("two features cannot share the outline progress {p1}"),
657            Err(index) => index,
658        };
659
660        let n = self.mapping.len();
661        if n >= 1 {
662            let (before1, before2) = self.mapping[(insertion + n - 1) % n];
663            let (after1, after2) = self.mapping[insertion % n];
664
665            // Pairs packed too closely together make the mapper unstable.
666            if progress_distance(p1, before1) < DISTANCE_EPSILON
667                || progress_distance(p1, after1) < DISTANCE_EPSILON
668                || progress_distance(p2, before2) < DISTANCE_EPSILON
669                || progress_distance(p2, after2) < DISTANCE_EPSILON
670            {
671                return;
672            }
673
674            // With two or more pairs already placed, the new one has to land
675            // between its neighbours on the *target* side too, or the outlines
676            // would cross while morphing.
677            if n > 1 && !progress_in_range(p2, before2, after2) {
678                return;
679            }
680        }
681
682        self.mapping.insert(insertion, (p1, p2));
683        self.used1[i1] = true;
684        self.used2[i2] = true;
685    }
686}
687
688// ---------------------------------------------------------------------------
689// Morph (morph.dart)
690// ---------------------------------------------------------------------------
691
692/// An interpolation between two [`RoundedPolygon`]s: at any progress `t` it
693/// yields one closed outline, the start shape at `0` and the end shape at `1`.
694///
695/// # Cost
696///
697/// **Building one is the expensive step** — measuring both outlines, pairing
698/// their corners and re-cutting the curves all happen in [`Morph::new`]. Build
699/// a morph once and keep it (in widget state, a `OnceLock`, a cache keyed by
700/// the shape pair); [`Morph::path`] is the per-frame call and does nothing but
701/// interpolate the stored curve pairs into the path it returns.
702#[derive(Clone, Debug)]
703pub struct Morph {
704    start: RoundedPolygon,
705    end: RoundedPolygon,
706    /// The two shapes' outlines cut into matching curves, one pair per curve of
707    /// the morphed outline. Interpolating every pair by the same progress is
708    /// the whole of the animation.
709    pairs: Vec<(Cubic, Cubic)>,
710}
711
712impl Morph {
713    /// Builds the morph from `start` to `end`.
714    ///
715    /// This measures both outlines, pairs their corners and re-cuts their
716    /// curves into matching pairs — see [`Morph`]'s note on cost for why that
717    /// belongs outside the frame loop.
718    ///
719    /// # Panics
720    ///
721    /// If either polygon has no measurable outline, or if two corners of one
722    /// shape sit at the same point along its outline (upstream throws in both
723    /// cases; every input here is a shape definition, not runtime data).
724    #[must_use]
725    pub fn new(start: RoundedPolygon, end: RoundedPolygon) -> Morph {
726        let pairs = Morph::match_polygons(&start, &end);
727        Morph { start, end, pairs }
728    }
729
730    /// The shape at progress `0`.
731    #[must_use]
732    pub const fn start(&self) -> &RoundedPolygon {
733        &self.start
734    }
735
736    /// The shape at progress `1`.
737    #[must_use]
738    pub const fn end(&self) -> &RoundedPolygon {
739        &self.end
740    }
741
742    /// Matches the two outlines curve for curve, cutting where they disagree.
743    fn match_polygons(p1: &RoundedPolygon, p2: &RoundedPolygon) -> Vec<(Cubic, Cubic)> {
744        let measured1 = MeasuredPolygon::measure(p1);
745        let measured2 = MeasuredPolygon::measure(p2);
746
747        let mapper = feature_mapper(&measured1.features, &measured2.features);
748
749        // Progress 0 on the first shape belongs at this point on the second,
750        // so that is where the second shape is re-cut to start.
751        let cut_point = mapper.map(0.0);
752        let bs1 = measured1;
753        let bs2 = measured2.cut_and_shift(cut_point);
754
755        let mut pairs: Vec<(Cubic, Cubic)> = Vec::new();
756        // The curve from each shape waiting to be matched, and the index the
757        // one after it will come from.
758        let mut curve1 = bs1.get(0);
759        let mut curve2 = bs2.get(0);
760        let mut i1 = 1;
761        let mut i2 = 1;
762
763        while let (Some(b1), Some(b2)) = (curve1, curve2) {
764            // Where each curve ends, in the *first* shape's progress space.
765            // The last curve of either shape ends at 1 by definition, whatever
766            // round-off says.
767            let b1_end = if i1 == bs1.cubics.len() {
768                1.0
769            } else {
770                b1.end_outline_progress
771            };
772            let b2_end = if i2 == bs2.cubics.len() {
773                1.0
774            } else {
775                mapper.map_back(positive_modulo(b2.end_outline_progress + cut_point, 1.0))
776            };
777
778            // Whichever curve ends first sets the boundary; a curve running
779            // past it is cut there, and its remainder matched next time round.
780            let boundary = b1_end.min(b2_end);
781
782            let (segment1, remainder1) = if b1_end > boundary + ANGLE_EPSILON {
783                let (cut, rest) = b1.cut_at_progress(boundary);
784                (cut, Some(rest))
785            } else {
786                let next = bs1.get(i1);
787                i1 += 1;
788                (b1, next)
789            };
790
791            let (segment2, remainder2) = if b2_end > boundary + ANGLE_EPSILON {
792                let (cut, rest) =
793                    b2.cut_at_progress(positive_modulo(mapper.map(boundary) - cut_point, 1.0));
794                (cut, Some(rest))
795            } else {
796                let next = bs2.get(i2);
797                i2 += 1;
798                (b2, next)
799            };
800
801            pairs.push((segment1.cubic, segment2.cubic));
802            curve1 = remainder1;
803            curve2 = remainder2;
804        }
805
806        debug_assert!(
807            curve1.is_none() && curve2.is_none(),
808            "both outlines must be fully matched"
809        );
810
811        pairs
812    }
813
814    /// The morphed outline at `progress`, as its curves.
815    ///
816    /// Allocates the returned list; [`Morph::path`] is the cheaper route when
817    /// the caller only wants to paint it.
818    #[must_use]
819    pub fn as_cubics(&self, progress: f64) -> Vec<Cubic> {
820        let mut cubics = Vec::with_capacity(self.pairs.len());
821        self.for_each_cubic(progress, |cubic| cubics.push(cubic));
822        cubics
823    }
824
825    /// The morphed outline at `progress` as a closed [`kurbo::BezPath`]: one
826    /// `MoveTo`, one `CurveTo` per curve, and a `ClosePath`.
827    ///
828    /// `progress` is not clamped — a caller feeding a spring value may pass a
829    /// little past `0`/`1` to keep the spring's overshoot, as long as it stays
830    /// close (far outside the range the outline is undefined).
831    ///
832    /// This is the per-frame call: it allocates the path it returns and nothing
833    /// else.
834    #[must_use]
835    pub fn path(&self, progress: f64) -> BezPath {
836        let mut path = BezPath::new();
837        let mut started = false;
838        self.for_each_cubic(progress, |cubic| {
839            if !started {
840                path.move_to(cubic.anchor0());
841                started = true;
842            }
843            path.curve_to(cubic.control0(), cubic.control1(), cubic.anchor1());
844        });
845        if started {
846            path.close_path();
847        }
848        path
849    }
850
851    /// Walks the morphed outline at `progress`, curve by curve.
852    ///
853    /// The last curve is rebuilt to end exactly on the first one's start
854    /// anchor: even a sub-pixel gap between the two shows as a seam when the
855    /// shape is filled (the same closure [`RoundedPolygon`] applies to its own
856    /// cubic list).
857    fn for_each_cubic(&self, progress: f64, mut emit: impl FnMut(Cubic)) {
858        let Some((first_start, first_end)) = self.pairs.first() else {
859            return;
860        };
861        let first_anchor = interpolate(first_start, first_end, progress).anchor0();
862
863        let last = self.pairs.len() - 1;
864        for (i, (start, end)) in self.pairs.iter().enumerate() {
865            let cubic = interpolate(start, end, progress);
866            if i == last {
867                emit(Cubic::new(
868                    cubic.anchor0(),
869                    cubic.control0(),
870                    cubic.control1(),
871                    first_anchor,
872                ));
873            } else {
874                emit(cubic);
875            }
876        }
877    }
878
879    /// The axis-aligned bounds of every anchor and control point of both
880    /// shapes — cheap, and never tighter than [`Morph::exact_bounds`].
881    ///
882    /// This is the size to reserve for a morph: an interpolated control point
883    /// is a blend of the two shapes' own, so for any progress in `0..=1` the
884    /// outline stays inside the hull these bounds describe (an overshooting
885    /// progress can leave it).
886    #[must_use]
887    pub fn bounds(&self) -> Rect {
888        self.start.bounds().union(self.end.bounds())
889    }
890
891    /// The true axis-aligned bounds of both shapes' outlines.
892    ///
893    /// Holds the outline at progress `0` and `1` exactly; in between, an
894    /// interpolated control point can pull a curve marginally outside it, which
895    /// is why [`Morph::bounds`] is the safer reservation.
896    #[must_use]
897    pub fn exact_bounds(&self) -> Rect {
898        self.start.exact_bounds().union(self.end.exact_bounds())
899    }
900
901    /// The smallest square holding either shape in *any* rotation — what a UI
902    /// element hosting a spinning morph needs to reserve.
903    #[must_use]
904    pub fn max_bounds(&self) -> Rect {
905        self.start.max_bounds().union(self.end.max_bounds())
906    }
907}
908
909/// Interpolates two curves element-wise on the flat eight-float layout.
910///
911/// Upstream's exact `lerp` form — `start * (1 - fraction) + stop * fraction` —
912/// which is also what makes the endpoints exact: at `0` every term of the
913/// second curve is multiplied by zero, at `1` every term of the first is.
914fn interpolate(start: &Cubic, end: &Cubic, progress: f64) -> Cubic {
915    let (a, b) = (start.points(), end.points());
916    let mut points = [0.0_f64; 8];
917    for (i, point) in points.iter_mut().enumerate() {
918        *point = a[i] * (1.0 - progress) + b[i] * progress;
919    }
920    Cubic::from_raw(points)
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use crate::shapes::{CornerRounding, StarParams};
927    use kurbo::{CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Shape};
928
929    /// Tolerance for the transcribed Dart goldens. The Dart source prints
930    /// shortest-round-trip doubles, so every literal parses back to the exact
931    /// `f64` upstream held; anything above pure operation-ordering noise here
932    /// is a real divergence.
933    const GOLDEN_EPSILON: f64 = 1e-12;
934
935    /// Upstream's own test epsilon (`test/test_utils.dart`), used wherever an
936    /// assertion is analytic rather than a recorded value.
937    const EPSILON: f64 = 1e-4;
938
939    fn triangle() -> RoundedPolygon {
940        RoundedPolygon::from_num_vertices(3, 1.0, Point::ZERO, CornerRounding::UNROUNDED, None)
941    }
942
943    fn square() -> RoundedPolygon {
944        RoundedPolygon::from_num_vertices(4, 1.0, Point::ZERO, CornerRounding::UNROUNDED, None)
945    }
946
947    fn octagon() -> RoundedPolygon {
948        RoundedPolygon::from_num_vertices(8, 1.0, Point::ZERO, CornerRounding::UNROUNDED, None)
949    }
950
951    fn circle8() -> RoundedPolygon {
952        RoundedPolygon::circle(8, 1.0, Point::ZERO)
953    }
954
955    fn rounded_square() -> RoundedPolygon {
956        RoundedPolygon::rectangle(2.0, 2.0, CornerRounding::new(0.3, 0.5), None, Point::ZERO)
957    }
958
959    fn star5() -> RoundedPolygon {
960        RoundedPolygon::star(StarParams {
961            num_vertices_per_radius: 5,
962            inner_radius: 0.5,
963            rounding: CornerRounding::radius(0.2),
964            ..StarParams::default()
965        })
966    }
967
968    /// The curves of a path, as `kurbo` segments.
969    fn segments(path: &BezPath) -> Vec<CubicBez> {
970        let mut start = Point::ZERO;
971        let mut previous = Point::ZERO;
972        let mut out = Vec::new();
973        for element in path.elements() {
974            match *element {
975                PathEl::MoveTo(p) => {
976                    start = p;
977                    previous = p;
978                }
979                PathEl::CurveTo(c0, c1, p) => {
980                    out.push(CubicBez::new(previous, c0, c1, p));
981                    previous = p;
982                }
983                PathEl::ClosePath => previous = start,
984                other => panic!("unexpected path element {other:?}"),
985            }
986        }
987        out
988    }
989
990    /// The distance from `point` to the nearest point of `path`.
991    fn distance_to(path: &BezPath, point: Point) -> f64 {
992        segments(path)
993            .iter()
994            .map(|segment| segment.nearest(point, 1e-12).distance_sq.sqrt())
995            .fold(f64::INFINITY, f64::min)
996    }
997
998    /// Asserts both outlines trace the same curve, by sampling each against the
999    /// other (one direction alone would pass for a path covering only part of
1000    /// the other).
1001    fn assert_same_outline(a: &BezPath, b: &BezPath, tolerance: f64, what: &str) {
1002        for (from, to, direction) in [(a, b, "a->b"), (b, a, "b->a")] {
1003            for segment in segments(from) {
1004                for i in 0..=8 {
1005                    let point = segment.eval(f64::from(i) / 8.0);
1006                    let distance = distance_to(to, point);
1007                    assert!(
1008                        distance < tolerance,
1009                        "{what} ({direction}): {point:?} is {distance} off the other outline"
1010                    );
1011                }
1012            }
1013        }
1014    }
1015
1016    fn assert_cubics_match_golden(cubics: &[Cubic], golden: &[[f64; 8]], name: &str) {
1017        assert_eq!(cubics.len(), golden.len(), "{name}: curve count drifted");
1018        for (i, (cubic, want)) in cubics.iter().zip(golden).enumerate() {
1019            for (j, (got, want)) in cubic.points().iter().zip(want).enumerate() {
1020                assert!(
1021                    (got - want).abs() < GOLDEN_EPSILON,
1022                    "{name}: curve {i} coordinate {j}: expected {want}, got {got}"
1023                );
1024            }
1025        }
1026    }
1027
1028    fn assert_close(expected: f64, actual: f64, what: &str) {
1029        assert!(
1030            (expected - actual).abs() < EPSILON,
1031            "{what}: expected {expected}, got {actual}"
1032        );
1033    }
1034
1035    // -----------------------------------------------------------------------
1036    // Progress arithmetic
1037    // -----------------------------------------------------------------------
1038
1039    #[test]
1040    fn positive_modulo_stays_below_the_modulus() {
1041        assert_close(1.0, positive_modulo(4.0, 3.0), "4 mod 3");
1042        assert_close(2.0, positive_modulo(-4.0, 3.0), "-4 mod 3");
1043        // The case rem_euclid alone gets wrong: a tiny negative rounds up to
1044        // the modulus itself.
1045        assert_eq!(positive_modulo(-1e-18, 1.0), 0.0);
1046    }
1047
1048    #[test]
1049    fn progress_range_wraps_around_zero() {
1050        assert!(progress_in_range(0.5, 0.2, 0.7));
1051        assert!(!progress_in_range(0.1, 0.2, 0.7));
1052        // A range whose end is below its start is the one crossing 0.
1053        assert!(progress_in_range(0.8, 0.7, 0.2));
1054        assert!(progress_in_range(0.1, 0.7, 0.2));
1055        assert!(!progress_in_range(0.5, 0.7, 0.2));
1056    }
1057
1058    #[test]
1059    fn progress_distance_takes_the_short_way_round() {
1060        assert_close(0.01, progress_distance(0.99, 0.0), "0.99 to 0");
1061        assert_close(0.2, progress_distance(0.3, 0.5), "0.3 to 0.5");
1062    }
1063
1064    /// Ported from upstream's `FloatMapping` suite: every mapper must agree
1065    /// with its closed form in both directions, everywhere.
1066    fn validate_mapping(mapper: &DoubleMapper, expected: impl Fn(f64) -> f64) {
1067        for i in 0..10_000 {
1068            let source = f64::from(i) / 10_000.0;
1069            let target = expected(source);
1070            assert_close(target, mapper.map(source), "map");
1071            assert_close(source, mapper.map_back(target), "map back");
1072        }
1073    }
1074
1075    #[test]
1076    fn identity_mapping() {
1077        validate_mapping(&DoubleMapper::new(&[(0.0, 0.0), (0.5, 0.5)]), |x| x);
1078    }
1079
1080    #[test]
1081    fn simple_mapping() {
1082        // The first half of the source becomes the first quarter of the target.
1083        validate_mapping(&DoubleMapper::new(&[(0.0, 0.0), (0.5, 0.25)]), |x| {
1084            if x < 0.5 {
1085                x / 2.0
1086            } else {
1087                (3.0 * x - 1.0) / 2.0
1088            }
1089        });
1090    }
1091
1092    #[test]
1093    fn wrapping_mappings_are_still_bijections() {
1094        // All three are the same "+ 0.5" or identity function, expressed with
1095        // the wrap on the target, on the source, and on both.
1096        validate_mapping(&DoubleMapper::new(&[(0.0, 0.5), (0.1, 0.6)]), |x| {
1097            (x + 0.5) % 1.0
1098        });
1099        validate_mapping(&DoubleMapper::new(&[(0.5, 0.0), (0.1, 0.6)]), |x| {
1100            (x + 0.5) % 1.0
1101        });
1102        validate_mapping(
1103            &DoubleMapper::new(&[(0.5, 0.5), (0.75, 0.75), (0.1, 0.1), (0.49, 0.49)]),
1104            |x| x,
1105        );
1106    }
1107
1108    #[test]
1109    fn multiple_point_mapping() {
1110        validate_mapping(
1111            &DoubleMapper::new(&[(0.4, 0.2), (0.5, 0.22), (0.0, 0.8)]),
1112            |x| {
1113                if x < 0.4 {
1114                    (0.8 + x) % 1.0
1115                } else if x < 0.5 {
1116                    0.2 + (x - 0.4) / 5.0
1117                } else {
1118                    // 0.5 of source maps to 0.58 of target, hence the 1.16.
1119                    0.22 + (x - 0.5) * 1.16
1120                }
1121            },
1122        );
1123    }
1124
1125    #[test]
1126    #[should_panic(expected = "wraps more than once")]
1127    fn a_target_wrapping_twice_is_rejected() {
1128        let _ = DoubleMapper::new(&[(0.0, 0.0), (0.3, 0.6), (0.6, 0.3), (0.9, 0.9)]);
1129    }
1130
1131    #[test]
1132    #[should_panic(expected = "wraps more than once")]
1133    fn a_source_wrapping_twice_is_rejected() {
1134        let _ = DoubleMapper::new(&[(0.0, 0.0), (0.6, 0.3), (0.3, 0.6), (0.9, 0.9)]);
1135    }
1136
1137    // -----------------------------------------------------------------------
1138    // Measuring
1139    // -----------------------------------------------------------------------
1140
1141    /// Upstream's `irregularPolygonMeasure`: the measured curves have to tile
1142    /// `0..1` in order, and every corner's progress has to land in `0..1`.
1143    fn assert_measures_cleanly(polygon: &RoundedPolygon) -> MeasuredPolygon {
1144        let measured = MeasuredPolygon::measure(polygon);
1145        assert_eq!(measured.cubics[0].start_outline_progress, 0.0);
1146        assert_eq!(
1147            measured.cubics[measured.cubics.len() - 1].end_outline_progress,
1148            1.0
1149        );
1150        for (i, cubic) in measured.cubics.iter().enumerate() {
1151            if i > 0 {
1152                assert_eq!(
1153                    measured.cubics[i - 1].end_outline_progress,
1154                    cubic.start_outline_progress,
1155                    "curve {i} does not start where {} ended",
1156                    i - 1
1157                );
1158            }
1159            assert!(cubic.end_outline_progress >= cubic.start_outline_progress);
1160        }
1161        for (i, feature) in measured.features.iter().enumerate() {
1162            assert!(
1163                (0.0..1.0).contains(&feature.progress),
1164                "corner {i} has invalid progress {}",
1165                feature.progress
1166            );
1167        }
1168        measured
1169    }
1170
1171    #[test]
1172    fn a_regular_polygon_measures_one_curve_per_side() {
1173        for sides in [3, 5, 8, 12, 20] {
1174            let polygon = RoundedPolygon::from_num_vertices(
1175                sides,
1176                1.0,
1177                Point::ZERO,
1178                CornerRounding::UNROUNDED,
1179                None,
1180            );
1181            let measured = assert_measures_cleanly(&polygon);
1182            // The zero-length corner curves drop out, leaving the sides.
1183            assert_eq!(measured.cubics.len(), sides, "{sides}-gon");
1184            for (i, cubic) in measured.cubics.iter().enumerate() {
1185                assert_close(
1186                    i as f64 / sides as f64,
1187                    cubic.start_outline_progress,
1188                    "side start",
1189                );
1190            }
1191        }
1192    }
1193
1194    #[test]
1195    fn rounded_shapes_measure_cleanly() {
1196        for rounding in [0.15, 0.5, 1.0] {
1197            let hexagon = RoundedPolygon::from_num_vertices(
1198                6,
1199                1.0,
1200                Point::ZERO,
1201                CornerRounding::radius(rounding),
1202                None,
1203            );
1204            assert_measures_cleanly(&hexagon);
1205        }
1206        assert_measures_cleanly(&circle8());
1207        assert_measures_cleanly(&rounded_square());
1208        assert_measures_cleanly(&star5());
1209    }
1210
1211    #[test]
1212    fn the_measurer_approximates_a_circle_within_one_and_a_half_percent() {
1213        // White box: the measurer walks chords, so a circle — the worst case —
1214        // comes out short. Upstream pins the same 1.5%.
1215        let polygon = RoundedPolygon::circle(4, 1.0, Point::ZERO);
1216        let measured: f64 = polygon.cubics().iter().map(measure_cubic).sum();
1217        let expected = 2.0 * std::f64::consts::PI;
1218        assert!(
1219            (expected - measured).abs() < 0.015 * expected,
1220            "circle perimeter: expected ~{expected}, measured {measured}"
1221        );
1222    }
1223
1224    #[test]
1225    fn measured_progress_matches_the_dart_goldens() {
1226        // Recorded from `MeasuredPolygon.measurePolygon` upstream.
1227        let measured = MeasuredPolygon::measure(&rounded_square());
1228        let golden_ends = [
1229            0.032907413764029836,
1230            0.06996739408559419,
1231            0.10287480784962404,
1232            0.25,
1233            0.28290741376402984,
1234            0.3199673940855942,
1235            0.352874807849624,
1236            0.5,
1237            0.5329074137640298,
1238            0.5699673940855942,
1239            0.6028748078496241,
1240            0.75,
1241            0.7829074137640298,
1242            0.8199673940855942,
1243            0.8528748078496241,
1244            1.0,
1245        ];
1246        assert_eq!(measured.cubics.len(), golden_ends.len());
1247        for (i, (cubic, want)) in measured.cubics.iter().zip(&golden_ends).enumerate() {
1248            assert!(
1249                (cubic.end_outline_progress - want).abs() < GOLDEN_EPSILON,
1250                "curve {i}: expected end {want}, got {}",
1251                cubic.end_outline_progress
1252            );
1253        }
1254
1255        let golden_corners = [
1256            0.051437403924812,
1257            0.301437403924812,
1258            0.551437403924812,
1259            0.801437403924812,
1260        ];
1261        assert_eq!(measured.features.len(), golden_corners.len());
1262        for (i, (feature, want)) in measured.features.iter().zip(&golden_corners).enumerate() {
1263            assert!(
1264                (feature.progress - want).abs() < GOLDEN_EPSILON,
1265                "corner {i}: expected {want}, got {}",
1266                feature.progress
1267            );
1268            assert_eq!(feature.kind, FeatureKind::ConvexCorner);
1269        }
1270    }
1271
1272    #[test]
1273    fn a_star_alternates_convex_and_concave_corners() {
1274        let measured = MeasuredPolygon::measure(&star5());
1275        let kinds: Vec<FeatureKind> = measured.features.iter().map(|f| f.kind).collect();
1276        assert_eq!(kinds.len(), 10);
1277        for (i, kind) in kinds.iter().enumerate() {
1278            let want = if i % 2 == 0 {
1279                FeatureKind::ConvexCorner
1280            } else {
1281                FeatureKind::ConcaveCorner
1282            };
1283            assert_eq!(*kind, want, "corner {i}");
1284        }
1285    }
1286
1287    #[test]
1288    fn cutting_and_shifting_keeps_the_same_outline() {
1289        let measured = MeasuredPolygon::measure(&circle8());
1290        let before = measured.clone();
1291        let shifted = measured.cut_and_shift(0.4);
1292
1293        assert_eq!(shifted.cubics[0].start_outline_progress, 0.0);
1294        assert_eq!(
1295            shifted.cubics[shifted.cubics.len() - 1].end_outline_progress,
1296            1.0
1297        );
1298        // The outline is one curve longer (the cut one, in two halves) and
1299        // still contiguous.
1300        assert_eq!(shifted.cubics.len(), before.cubics.len() + 1);
1301        for pair in shifted.cubics.windows(2) {
1302            let (previous, next) = (pair[0].cubic.anchor1(), pair[1].cubic.anchor0());
1303            assert!((next - previous).hypot() < 1e-12);
1304        }
1305        // It starts where the cut was: 40% of the way round the old outline.
1306        let old_start = before
1307            .cubics
1308            .iter()
1309            .find(|c| c.start_outline_progress <= 0.4 && c.end_outline_progress >= 0.4)
1310            .expect("some curve spans 0.4")
1311            .cut_at_progress(0.4)
1312            .1
1313            .cubic
1314            .anchor0();
1315        assert!((shifted.cubics[0].cubic.anchor0() - old_start).hypot() < 1e-12);
1316    }
1317
1318    // -----------------------------------------------------------------------
1319    // Feature mapping
1320    // -----------------------------------------------------------------------
1321
1322    fn corners(polygon: &RoundedPolygon) -> Vec<ProgressableFeature> {
1323        MeasuredPolygon::measure(polygon)
1324            .features
1325            .into_iter()
1326            .filter(|f| f.kind != FeatureKind::Edge)
1327            .collect()
1328    }
1329
1330    fn assert_mapping_matches_golden(
1331        p1: &RoundedPolygon,
1332        p2: &RoundedPolygon,
1333        golden: &[(f64, f64)],
1334        name: &str,
1335    ) {
1336        let mapping = do_mapping(&corners(p1), &corners(p2));
1337        assert_eq!(mapping.len(), golden.len(), "{name}: pair count drifted");
1338        for (i, (got, want)) in mapping.iter().zip(golden).enumerate() {
1339            assert!(
1340                (got.0 - want.0).abs() < GOLDEN_EPSILON && (got.1 - want.1).abs() < GOLDEN_EPSILON,
1341                "{name}: pair {i}: expected {want:?}, got {got:?}"
1342            );
1343        }
1344    }
1345
1346    #[test]
1347    fn corner_pairing_matches_the_dart_goldens() {
1348        assert_mapping_matches_golden(
1349            &triangle(),
1350            &square(),
1351            &[
1352                (0.0, 0.0),
1353                (0.3333333333333335, 0.25),
1354                (0.6666666666666667, 0.75),
1355            ],
1356            "triangle -> square",
1357        );
1358        assert_mapping_matches_golden(
1359            &triangle(),
1360            &octagon(),
1361            &[
1362                (0.0, 0.0),
1363                (0.3333333333333335, 0.375),
1364                (0.6666666666666667, 0.625),
1365            ],
1366            "triangle -> octagon",
1367        );
1368        assert_mapping_matches_golden(
1369            &circle8(),
1370            &square(),
1371            &[(0.0625, 0.0), (0.3125, 0.25), (0.5625, 0.5), (0.8125, 0.75)],
1372            "circle -> square",
1373        );
1374        assert_mapping_matches_golden(
1375            &star5(),
1376            &circle8(),
1377            &[
1378                (0.0465277905440431, 0.0625),
1379                (0.24652779054404328, 0.3125),
1380                (0.44652779054404323, 0.4375),
1381                (0.6465277905440434, 0.6875),
1382                (0.8465277905440434, 0.8125),
1383            ],
1384            "star -> circle",
1385        );
1386    }
1387
1388    #[test]
1389    fn every_paired_corner_is_a_near_neighbour() {
1390        // Upstream's `feature mapping triangle to square`: one exact match
1391        // (both shapes have a vertex at 0°) and two close ones.
1392        let (c1, c2) = (corners(&triangle()), corners(&square()));
1393        let mut distances: Vec<f64> = do_mapping(&c1, &c2)
1394            .iter()
1395            .map(|&(p1, p2)| {
1396                let f1 = c1.iter().find(|f| f.progress == p1).expect("mapped corner");
1397                let f2 = c2.iter().find(|f| f.progress == p2).expect("mapped corner");
1398                feature_dist_squared(f1, f2).expect("corners of the same kind")
1399            })
1400            .collect();
1401        distances.sort_by(f64::total_cmp);
1402
1403        assert_eq!(distances.len(), 3);
1404        assert!(distances[0] < 1e-6, "the exact match: {}", distances[0]);
1405        assert_close(distances[1], distances[2], "the two symmetric matches");
1406        assert!(distances[2] < 0.3, "the worst match: {}", distances[2]);
1407    }
1408
1409    #[test]
1410    fn a_convex_corner_never_maps_to_a_concave_one() {
1411        let star = corners(&star5());
1412        let circle = corners(&circle8());
1413        let concave = star
1414            .iter()
1415            .find(|f| f.kind == FeatureKind::ConcaveCorner)
1416            .expect("a star has concave corners");
1417        assert!(feature_dist_squared(concave, &circle[0]).is_none());
1418
1419        // So a star's inner points sit out the mapping entirely: only its five
1420        // outer ones are paired.
1421        assert_eq!(do_mapping(&star, &circle).len(), 5);
1422    }
1423
1424    #[test]
1425    fn mapping_a_complicated_pair_does_not_crash() {
1426        // Upstream's regression case: a checkmark against a many-pointed star
1427        // used to crash the mapper.
1428        let checkmark = RoundedPolygon::from_vertices(
1429            &[
1430                Point::new(400.0, -304.0),
1431                Point::new(240.0, -464.0),
1432                Point::new(296.0, -520.0),
1433                Point::new(400.0, -416.0),
1434                Point::new(664.0, -680.0),
1435                Point::new(720.0, -624.0),
1436            ],
1437            CornerRounding::UNROUNDED,
1438            None,
1439            None,
1440        )
1441        .normalized();
1442        let very_sunny = RoundedPolygon::star(StarParams {
1443            num_vertices_per_radius: 8,
1444            inner_radius: 0.65,
1445            rounding: CornerRounding::radius(0.15),
1446            ..StarParams::default()
1447        })
1448        .normalized();
1449
1450        let mapping = do_mapping(&corners(&checkmark), &corners(&very_sunny));
1451        assert!(!mapping.is_empty());
1452        let _ = Morph::new(checkmark, very_sunny);
1453    }
1454
1455    // -----------------------------------------------------------------------
1456    // Morph: endpoints
1457    // -----------------------------------------------------------------------
1458
1459    #[test]
1460    fn the_endpoints_are_the_source_shapes_point_for_point() {
1461        // Recorded from `Morph.asCubics` upstream: a triangle morphing into a
1462        // square, at both ends. The four curves at progress 0 trace the
1463        // triangle (one side is cut in two, where the square has a corner the
1464        // triangle does not); at progress 1 they trace the square.
1465        let morph = Morph::new(triangle(), square());
1466        assert_cubics_match_golden(
1467            &morph.as_cubics(0.0),
1468            &[
1469                [
1470                    1.0,
1471                    0.0,
1472                    0.5000000000000001,
1473                    0.28867513459481287,
1474                    1.6653345369377348e-16,
1475                    0.5773502691896257,
1476                    -0.49999999999999983,
1477                    0.8660254037844387,
1478                ],
1479                [
1480                    -0.49999999999999983,
1481                    0.8660254037844387,
1482                    -0.4999999999999999,
1483                    0.5773502691896258,
1484                    -0.5,
1485                    0.288675134594813,
1486                    -0.5000000000000001,
1487                    2.7755575615628914e-17,
1488                ],
1489                [
1490                    -0.5000000000000001,
1491                    2.7755575615628914e-17,
1492                    -0.5000000000000002,
1493                    -0.28867513459481275,
1494                    -0.5000000000000003,
1495                    -0.5773502691896255,
1496                    -0.5000000000000004,
1497                    -0.8660254037844384,
1498                ],
1499                [
1500                    -0.5000000000000004,
1501                    -0.8660254037844384,
1502                    -3.3306690738754696e-16,
1503                    -0.5773502691896256,
1504                    0.4999999999999998,
1505                    -0.2886751345948128,
1506                    1.0,
1507                    0.0,
1508                ],
1509            ],
1510            "triangle -> square at 0",
1511        );
1512        assert_cubics_match_golden(
1513            &morph.as_cubics(1.0),
1514            &[
1515                [
1516                    1.0,
1517                    0.0,
1518                    0.6666666666666667,
1519                    0.3333333333333333,
1520                    0.3333333333333334,
1521                    0.6666666666666666,
1522                    6.123233995736766e-17,
1523                    1.0,
1524                ],
1525                [
1526                    6.123233995736766e-17,
1527                    1.0,
1528                    -0.33333333333333326,
1529                    0.6666666666666667,
1530                    -0.6666666666666666,
1531                    0.3333333333333334,
1532                    -1.0,
1533                    1.2246467991473532e-16,
1534                ],
1535                [
1536                    -1.0,
1537                    1.2246467991473532e-16,
1538                    -0.6666666666666669,
1539                    -0.33333333333333326,
1540                    -0.3333333333333335,
1541                    -0.6666666666666666,
1542                    -1.8369701987210297e-16,
1543                    -1.0,
1544                ],
1545                [
1546                    -1.8369701987210297e-16,
1547                    -1.0,
1548                    0.3333333333333332,
1549                    -0.6666666666666667,
1550                    0.6666666666666665,
1551                    -0.33333333333333337,
1552                    1.0,
1553                    0.0,
1554                ],
1555            ],
1556            "triangle -> square at 1",
1557        );
1558    }
1559
1560    #[test]
1561    fn the_endpoints_trace_the_source_outlines() {
1562        for (name, start, end) in [
1563            ("triangle -> square", triangle(), square()),
1564            ("triangle -> octagon", triangle(), octagon()),
1565            ("circle -> square", circle8(), square()),
1566            ("rounded square -> circle", rounded_square(), circle8()),
1567            ("star -> circle", star5(), circle8()),
1568        ] {
1569            let (start_path, end_path) = (start.to_path(), end.to_path());
1570            let morph = Morph::new(start.clone(), end.clone());
1571
1572            assert_same_outline(&morph.path(0.0), &start_path, 1e-9, &format!("{name} at 0"));
1573            assert_same_outline(&morph.path(1.0), &end_path, 1e-9, &format!("{name} at 1"));
1574
1575            // The area a fill covers is the same to within round-off, which no
1576            // partial or doubled cover could be.
1577            assert!(
1578                (morph.path(0.0).area() - start_path.area()).abs() < 1e-9,
1579                "{name}: filled area differs at 0"
1580            );
1581            assert!(
1582                (morph.path(1.0).area() - end_path.area()).abs() < 1e-9,
1583                "{name}: filled area differs at 1"
1584            );
1585        }
1586    }
1587
1588    #[test]
1589    fn a_shape_morphed_into_itself_never_moves() {
1590        for shape in [triangle(), circle8(), rounded_square(), star5()] {
1591            let path = shape.to_path();
1592            let morph = Morph::new(shape.clone(), shape.clone());
1593            for step in 0..=10 {
1594                let progress = f64::from(step) / 10.0;
1595                assert_same_outline(
1596                    &morph.path(progress),
1597                    &path,
1598                    1e-9,
1599                    &format!("self-morph at {progress}"),
1600                );
1601            }
1602        }
1603    }
1604
1605    // -----------------------------------------------------------------------
1606    // Morph: in between
1607    // -----------------------------------------------------------------------
1608
1609    #[test]
1610    fn the_halfway_outline_matches_the_dart_goldens() {
1611        // A circle and a square: eight curves against four, so the matching
1612        // cuts both into twelve.
1613        assert_cubics_match_golden(
1614            &Morph::new(circle8(), square()).as_cubics(0.5),
1615            &[
1616                [
1617                    0.8369397662556435,
1618                    -0.31634171618254486,
1619                    0.9039799220852145,
1620                    -0.2134180278540675,
1621                    0.9583333333333333,
1622                    -0.10670901392703358,
1623                    1.0,
1624                    3.0878077872387166e-16,
1625                ],
1626                [
1627                    1.0,
1628                    3.0878077872387166e-16,
1629                    0.9583333333333335,
1630                    0.10670901392703386,
1631                    0.9039799220852147,
1632                    0.21341802785406744,
1633                    0.8369397662556439,
1634                    0.3163417161825445,
1635                ],
1636                [
1637                    0.8369397662556439,
1638                    0.3163417161825445,
1639                    0.7028594545965018,
1640                    0.522189092839499,
1641                    0.5221890928395,
1642                    0.7028594545965007,
1643                    0.3163417161825455,
1644                    0.8369397662556429,
1645                ],
1646                [
1647                    0.3163417161825455,
1648                    0.836939766255643,
1649                    0.21341802785406783,
1650                    0.9039799220852143,
1651                    0.10670901392703366,
1652                    0.9583333333333334,
1653                    -4.967397667182656e-16,
1654                    1.0,
1655                ],
1656                [
1657                    -4.967397667182656e-16,
1658                    1.0,
1659                    -0.10670901392703397,
1660                    0.9583333333333336,
1661                    -0.2134180278540674,
1662                    0.9039799220852148,
1663                    -0.31634171618254436,
1664                    0.836939766255644,
1665                ],
1666                [
1667                    -0.31634171618254436,
1668                    0.8369397662556439,
1669                    -0.522189092839499,
1670                    0.7028594545965017,
1671                    -0.7028594545965008,
1672                    0.5221890928395001,
1673                    -0.836939766255643,
1674                    0.31634171618254536,
1675                ],
1676                [
1677                    -0.8369397662556431,
1678                    0.31634171618254536,
1679                    -0.9039799220852144,
1680                    0.21341802785406774,
1681                    -0.9583333333333335,
1682                    0.10670901392703358,
1683                    -1.0,
1684                    -5.563292174903757e-16,
1685                ],
1686                [
1687                    -1.0,
1688                    -5.563292174903757e-16,
1689                    -0.9583333333333335,
1690                    -0.10670901392703397,
1691                    -0.9039799220852148,
1692                    -0.21341802785406735,
1693                    -0.836939766255644,
1694                    -0.3163417161825443,
1695                ],
1696                [
1697                    -0.8369397662556439,
1698                    -0.3163417161825443,
1699                    -0.7028594545965017,
1700                    -0.522189092839499,
1701                    -0.5221890928395001,
1702                    -0.7028594545965007,
1703                    -0.3163417161825455,
1704                    -0.836939766255643,
1705                ],
1706                [
1707                    -0.3163417161825455,
1708                    -0.836939766255643,
1709                    -0.213418027854068,
1710                    -0.9039799220852143,
1711                    -0.10670901392703391,
1712                    -0.9583333333333334,
1713                    1.3366554194093344e-16,
1714                    -1.0,
1715                ],
1716                [
1717                    1.3366554194093344e-16,
1718                    -1.0,
1719                    0.10670901392703383,
1720                    -0.9583333333333334,
1721                    0.2134180278540675,
1722                    -0.9039799220852145,
1723                    0.3163417161825447,
1724                    -0.8369397662556435,
1725                ],
1726                [
1727                    0.31634171618254475,
1728                    -0.8369397662556435,
1729                    0.5221890928394994,
1730                    -0.7028594545965013,
1731                    0.7028594545965011,
1732                    -0.5221890928394997,
1733                    0.8369397662556435,
1734                    -0.31634171618254486,
1735                ],
1736            ],
1737            "circle -> square at 0.5",
1738        );
1739    }
1740
1741    #[test]
1742    fn morphing_across_differing_corner_counts_matches_the_dart_golden() {
1743        // Three corners against eight: the triangle's sides are cut so both
1744        // outlines walk as eight curves.
1745        assert_cubics_match_golden(
1746            &Morph::new(triangle(), octagon()).as_cubics(0.5),
1747            &[
1748                [
1749                    1.0,
1750                    0.0,
1751                    0.867851130197758,
1752                    0.16596365263022672,
1753                    0.7357022603955158,
1754                    0.33192730526045344,
1755                    0.6035533905932737,
1756                    0.4978909578906803,
1757                ],
1758                [
1759                    0.6035533905932737,
1760                    0.4978909578906803,
1761                    0.40236892706218247,
1762                    0.5948190167920578,
1763                    0.2011844635310912,
1764                    0.6917470756934354,
1765                    -1.1510060200336796e-16,
1766                    0.788675134594813,
1767                ],
1768                [
1769                    -1.1510060200336796e-16,
1770                    0.788675134594813,
1771                    -0.2011844635310913,
1772                    0.7879721205583732,
1773                    -0.4023689270621824,
1774                    0.787269106521933,
1775                    -0.6035533905932736,
1776                    0.7865660924854931,
1777                ],
1778                [
1779                    -0.6035533905932736,
1780                    0.7865660924854931,
1781                    -0.6523689270621824,
1782                    0.5243773949903291,
1783                    -0.7011844635310912,
1784                    0.26218869749516494,
1785                    -0.75,
1786                    7.481828364441833e-16,
1787                ],
1788                [
1789                    -0.75,
1790                    7.481828364441833e-16,
1791                    -0.7011844635310914,
1792                    -0.2621886974951638,
1793                    -0.6523689270621827,
1794                    -0.5243773949903283,
1795                    -0.6035533905932741,
1796                    -0.7865660924854929,
1797                ],
1798                [
1799                    -0.6035533905932741,
1800                    -0.7865660924854929,
1801                    -0.40236892706218286,
1802                    -0.7872691065219329,
1803                    -0.20118446353109162,
1804                    -0.7879721205583728,
1805                    -3.971598417079695e-16,
1806                    -0.7886751345948129,
1807                ],
1808                [
1809                    -3.971598417079695e-16,
1810                    -0.7886751345948129,
1811                    0.2011844635310909,
1812                    -0.6917470756934354,
1813                    0.4023689270621822,
1814                    -0.5948190167920578,
1815                    0.6035533905932735,
1816                    -0.4978909578906803,
1817                ],
1818                [
1819                    0.6035533905932735,
1820                    -0.4978909578906803,
1821                    0.7357022603955157,
1822                    -0.33192730526045355,
1823                    0.8678511301977578,
1824                    -0.16596365263022678,
1825                    1.0,
1826                    0.0,
1827                ],
1828            ],
1829            "triangle -> octagon at 0.5",
1830        );
1831    }
1832
1833    #[test]
1834    fn morphing_a_shape_with_concave_corners_matches_the_dart_golden() {
1835        // A star's inner points sit out the corner pairing, so its curves are
1836        // carried along by the mapping of the outer ones — the case a mistake
1837        // in the concave path shows up in. Recorded from upstream; the start
1838        // anchors alone pin the whole walk, since each curve begins where the
1839        // one before it ended.
1840        let cubics = Morph::new(star5(), circle8()).as_cubics(0.5);
1841        let golden = [
1842            (0.785330199718972, -0.2682354889504666),
1843            (0.785327440564177, 0.2682427047337134),
1844            (0.7729607641303604, 0.28460842940814013),
1845            (0.6552523839451216, 0.4157139686894965),
1846            (0.486649700963247, 0.6048840395563428),
1847            (0.37615440549992074, 0.7289173135928099),
1848            (0.36220709983559274, 0.7420407503987456),
1849            (-0.0776730888836798, 0.8221309185364376),
1850            (-0.19387659974157886, 0.7513254080021173),
1851            (-0.2726062554084669, 0.7120463007873984),
1852            (-0.35736358560669745, 0.6914924591704735),
1853            (-0.4885340974522692, 0.6713420091019238),
1854            (-0.7667404101680318, 0.31848023965843286),
1855            (-0.7683818377075402, 0.29870091587759084),
1856            (-0.7679462219911052, 0.12701645351874605),
1857            (-0.7679304833770698, -0.12711291114449055),
1858            (-0.7683818377075398, -0.29870091587759007),
1859            (-0.7667216169895471, -0.31852230735243764),
1860            (-0.48831593733694323, -0.6714922788437413),
1861            (-0.3571801349513464, -0.6915865496117093),
1862            (-0.27260625540846845, -0.7120463007873981),
1863            (-0.19368034855012445, -0.7513885952154867),
1864            (-0.07741256963330226, -0.8221789242799227),
1865            (0.3622501349712382, -0.7420242928309926),
1866            (0.37615440549992063, -0.7289173135928098),
1867            (0.4867290356852201, -0.6048269625957917),
1868            (0.6553094609056724, -0.415634633967524),
1869            (0.7729607641303599, -0.2846084294081404),
1870        ];
1871
1872        assert_eq!(cubics.len(), golden.len(), "curve count drifted");
1873        for (i, (cubic, (x, y))) in cubics.iter().zip(&golden).enumerate() {
1874            let anchor = cubic.anchor0();
1875            assert!(
1876                (anchor.x - x).abs() < GOLDEN_EPSILON && (anchor.y - y).abs() < GOLDEN_EPSILON,
1877                "curve {i}: expected start ({x}, {y}), got {anchor:?}"
1878            );
1879        }
1880    }
1881
1882    #[test]
1883    fn the_halfway_outline_is_closed_simple_and_inside_both_shapes() {
1884        let (start, end) = (circle8(), square());
1885        let union = Morph::new(start.clone(), end.clone()).bounds();
1886        let morph = Morph::new(start, end);
1887        let path = morph.path(0.5);
1888
1889        // Closed: the last curve lands exactly on the opening move, and the
1890        // path says so.
1891        let elements = path.elements();
1892        let Some(PathEl::MoveTo(first)) = elements.first().copied() else {
1893            panic!("a path opens with a MoveTo");
1894        };
1895        assert!(matches!(elements.last(), Some(PathEl::ClosePath)));
1896        let curves = segments(&path);
1897        assert_eq!(
1898            curves[curves.len() - 1].p3,
1899            first,
1900            "the outline must close exactly"
1901        );
1902        for pair in curves.windows(2) {
1903            assert_eq!(pair[0].p3, pair[1].p0, "the outline must be contiguous");
1904        }
1905
1906        // Inside the union of both shapes' bounds.
1907        for curve in &curves {
1908            for i in 0..=8 {
1909                let point = curve.eval(f64::from(i) / 8.0);
1910                assert!(
1911                    union.inflate(1e-9, 1e-9).contains(point),
1912                    "{point:?} escapes the union bounds {union:?}"
1913                );
1914            }
1915        }
1916
1917        // Simple (non-self-intersecting): a point can only be inside a closed
1918        // curve once, so no winding number may exceed 1 in magnitude, and every
1919        // interior point has to agree on the sign — a crossing shows up as a
1920        // lobe wound the other way, or wound twice.
1921        let mut inside = 0_u32;
1922        let mut sign = 0_i32;
1923        for row in 0..48 {
1924            for column in 0..48 {
1925                let point = Point::new(
1926                    union.x0 + union.width() * (f64::from(column) + 0.5) / 48.0,
1927                    union.y0 + union.height() * (f64::from(row) + 0.5) / 48.0,
1928                );
1929                let winding = path.winding(point);
1930                assert!(
1931                    (-1..=1).contains(&winding),
1932                    "winding {winding} at {point:?}: the outline crosses itself"
1933                );
1934                if winding != 0 {
1935                    assert!(
1936                        sign == 0 || sign == winding,
1937                        "winding {winding} at {point:?} disagrees with {sign} elsewhere"
1938                    );
1939                    sign = winding;
1940                    inside += 1;
1941                }
1942            }
1943        }
1944        assert!(inside > 1000, "only {inside} of 2304 samples landed inside");
1945    }
1946
1947    #[test]
1948    fn the_bounds_hold_both_shapes() {
1949        let morph = Morph::new(rounded_square(), circle8());
1950        let bounds = morph.bounds();
1951        for polygon in [morph.start(), morph.end()] {
1952            let shape = polygon.bounds();
1953            assert!(
1954                bounds.union(shape) == bounds,
1955                "{shape:?} escapes the union {bounds:?}"
1956            );
1957        }
1958        assert!(morph.exact_bounds().area() <= bounds.area());
1959        // A shape spun about its centre needs the square, which is at least as
1960        // wide as the shape itself.
1961        let max = morph.max_bounds();
1962        assert!(max.width() >= morph.exact_bounds().width() - EPSILON);
1963        assert_close(max.width(), max.height(), "max bounds is a square");
1964    }
1965
1966    #[test]
1967    fn a_hundred_samples_of_each_pair_are_well_formed() {
1968        for (name, start, end) in [
1969            ("triangle -> octagon", triangle(), octagon()),
1970            ("circle -> square", circle8(), square()),
1971            ("star -> rounded square", star5(), rounded_square()),
1972        ] {
1973            let morph = Morph::new(start, end);
1974            let curve_count = morph.as_cubics(0.0).len();
1975            for step in 0..100 {
1976                let progress = f64::from(step) / 99.0;
1977                let path = morph.path(progress);
1978                let curves = segments(&path);
1979                assert_eq!(curves.len(), curve_count, "{name} at {progress}");
1980                assert!(matches!(path.elements().last(), Some(PathEl::ClosePath)));
1981                for curve in &curves {
1982                    for value in [curve.p0, curve.p1, curve.p2, curve.p3] {
1983                        assert!(
1984                            value.x.is_finite() && value.y.is_finite(),
1985                            "{name} at {progress}: non-finite point {value:?}"
1986                        );
1987                    }
1988                }
1989            }
1990        }
1991    }
1992}