Skip to main content

oxframe_core/
path.rs

1//! Morphing between arbitrary [`BezPath`]s.
2//!
3//! Both paths are normalized to closed or open runs of cubic segments, the
4//! runs are paired up, segment counts are equalized by splitting the longest
5//! segments, and for closed runs the start point and winding direction are
6//! chosen to minimize the distance between corresponding points. The aligned
7//! pair is cached, so a tween aligns once and then only interpolates.
8
9use std::cell::RefCell;
10use std::hash::{Hash, Hasher};
11
12use kurbo::{BezPath, CubicBez, ParamCurve, PathEl, Point, Vec2};
13
14use crate::Lerp;
15
16#[derive(Debug, Clone, PartialEq)]
17struct Run {
18    segs: Vec<CubicBez>,
19    closed: bool,
20}
21
22impl Run {
23    fn start(&self) -> Point {
24        self.segs.first().map_or(Point::ZERO, |s| s.p0)
25    }
26
27    fn centroid(&self) -> Point {
28        if self.segs.is_empty() {
29            return Point::ZERO;
30        }
31        let sum = self
32            .segs
33            .iter()
34            .fold(Vec2::ZERO, |acc, s| acc + s.p0.to_vec2());
35        (sum / self.segs.len() as f64).to_point()
36    }
37
38    /// Signed area of the control polygon; the sign gives the direction.
39    fn signed_area(&self) -> f64 {
40        self.segs
41            .iter()
42            .map(|s| {
43                let pts = [s.p0, s.p1, s.p2, s.p3];
44                pts.windows(2)
45                    .map(|w| w[0].x * w[1].y - w[1].x * w[0].y)
46                    .sum::<f64>()
47            })
48            .sum::<f64>()
49            / 2.0
50    }
51
52    fn reversed(&self) -> Run {
53        Run {
54            segs: self
55                .segs
56                .iter()
57                .rev()
58                .map(|s| CubicBez::new(s.p3, s.p2, s.p1, s.p0))
59                .collect(),
60            closed: self.closed,
61        }
62    }
63
64    fn rotated(&self, k: usize) -> Run {
65        let mut segs = self.segs.clone();
66        segs.rotate_left(k);
67        Run {
68            segs,
69            closed: self.closed,
70        }
71    }
72
73    /// A degenerate run of `n` zero-length segments at `p`.
74    fn point(p: Point, n: usize, closed: bool) -> Run {
75        Run {
76            segs: vec![CubicBez::new(p, p, p, p); n.max(1)],
77            closed,
78        }
79    }
80
81    /// Splits the longest segments until there are `n`.
82    fn split_to(&mut self, n: usize) {
83        while self.segs.len() < n {
84            let (i, _) = self
85                .segs
86                .iter()
87                .enumerate()
88                .map(|(i, s)| (i, s.p0.distance(s.p3) + 1e-9 * i as f64))
89                .fold(
90                    (0, f64::MIN),
91                    |best, cur| if cur.1 > best.1 { cur } else { best },
92                );
93            let s = self.segs[i];
94            let (a, b) = (s.subsegment(0.0..0.5), s.subsegment(0.5..1.0));
95            self.segs.splice(i..=i, [a, b]);
96        }
97    }
98
99    fn to_path(&self, out: &mut BezPath) {
100        let Some(first) = self.segs.first() else {
101            return;
102        };
103        out.move_to(first.p0);
104        for s in &self.segs {
105            out.curve_to(s.p1, s.p2, s.p3);
106        }
107        if self.closed {
108            out.close_path();
109        }
110    }
111}
112
113fn line(a: Point, b: Point) -> CubicBez {
114    CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b)
115}
116
117fn runs(path: &BezPath) -> Vec<Run> {
118    let mut out: Vec<Run> = Vec::new();
119    let mut cur: Option<Run> = None;
120    let (mut start, mut last) = (Point::ZERO, Point::ZERO);
121    let finish = |cur: &mut Option<Run>, out: &mut Vec<Run>| {
122        if let Some(r) = cur.take()
123            && !r.segs.is_empty()
124        {
125            out.push(r);
126        }
127    };
128    for el in path.elements() {
129        match *el {
130            PathEl::MoveTo(p) => {
131                finish(&mut cur, &mut out);
132                cur = Some(Run {
133                    segs: Vec::new(),
134                    closed: false,
135                });
136                (start, last) = (p, p);
137            }
138            PathEl::LineTo(p) => {
139                cur.get_or_insert_with(|| Run {
140                    segs: Vec::new(),
141                    closed: false,
142                })
143                .segs
144                .push(line(last, p));
145                last = p;
146            }
147            PathEl::QuadTo(c, p) => {
148                let q = kurbo::QuadBez::new(last, c, p).raise();
149                cur.get_or_insert_with(|| Run {
150                    segs: Vec::new(),
151                    closed: false,
152                })
153                .segs
154                .push(q);
155                last = p;
156            }
157            PathEl::CurveTo(a, b, p) => {
158                cur.get_or_insert_with(|| Run {
159                    segs: Vec::new(),
160                    closed: false,
161                })
162                .segs
163                .push(CubicBez::new(last, a, b, p));
164                last = p;
165            }
166            PathEl::ClosePath => {
167                if let Some(r) = cur.as_mut() {
168                    if last.distance(start) > 1e-9 {
169                        r.segs.push(line(last, start));
170                    }
171                    r.closed = true;
172                }
173                finish(&mut cur, &mut out);
174                last = start;
175            }
176        }
177    }
178    finish(&mut cur, &mut out);
179    out
180}
181
182/// Makes `b` correspond point-by-point to `a` (same counts, best start).
183fn align_run(a: &mut Run, b: &mut Run) {
184    let n = a.segs.len().max(b.segs.len());
185    a.split_to(n);
186    b.split_to(n);
187    if !(a.closed && b.closed) {
188        return;
189    }
190    if a.signed_area() * b.signed_area() < 0.0 {
191        *b = b.reversed();
192    }
193    let cost = |k: usize| -> f64 {
194        (0..n)
195            .map(|i| a.segs[i].p0.distance_squared(b.segs[(i + k) % n].p0))
196            .sum()
197    };
198    let best = (0..n)
199        .map(|k| (k, cost(k)))
200        .fold(
201            (0, f64::MAX),
202            |best, cur| if cur.1 < best.1 { cur } else { best },
203        )
204        .0;
205    *b = b.rotated(best);
206}
207
208fn align(from: &BezPath, to: &BezPath) -> (Vec<Run>, Vec<Run>) {
209    let mut a = runs(from);
210    let mut b = runs(to);
211    // Pair runs by order; a missing partner grows from / shrinks to a point
212    // at the centroid of the existing run.
213    let n = a.len().max(b.len());
214    for i in 0..n {
215        match (a.get(i), b.get(i)) {
216            (Some(r), None) => b.push(Run::point(r.centroid(), r.segs.len(), r.closed)),
217            (None, Some(r)) => a.push(Run::point(r.centroid(), r.segs.len(), r.closed)),
218            _ => {}
219        }
220    }
221    for (ra, rb) in a.iter_mut().zip(b.iter_mut()) {
222        if ra.closed != rb.closed {
223            ra.closed = true;
224            rb.closed = true;
225            // Close the open run back to its start so the shapes agree.
226            for r in [&mut *ra, &mut *rb] {
227                let (s, e) = (r.start(), r.segs.last().map_or(r.start(), |c| c.p3));
228                if s.distance(e) > 1e-9 {
229                    r.segs.push(line(e, s));
230                }
231            }
232        }
233        align_run(ra, rb);
234    }
235    (a, b)
236}
237
238fn path_hash(p: &BezPath, h: &mut impl Hasher) {
239    for el in p.elements() {
240        let pts: &[Point] = match el {
241            PathEl::MoveTo(p) | PathEl::LineTo(p) => std::slice::from_ref(p),
242            PathEl::QuadTo(a, b) => &[*a, *b],
243            PathEl::CurveTo(a, b, c) => &[*a, *b, *c],
244            PathEl::ClosePath => &[],
245        };
246        std::mem::discriminant(el).hash(h);
247        for p in pts {
248            p.x.to_bits().hash(h);
249            p.y.to_bits().hash(h);
250        }
251    }
252}
253
254type Aligned = (u64, std::rc::Rc<(Vec<Run>, Vec<Run>)>);
255
256thread_local! {
257    static CACHE: RefCell<Vec<Aligned>> = const { RefCell::new(Vec::new()) };
258}
259
260const CACHE_SIZE: usize = 16;
261
262fn aligned(from: &BezPath, to: &BezPath) -> std::rc::Rc<(Vec<Run>, Vec<Run>)> {
263    // `DefaultHasher::new()` uses fixed keys, so this is deterministic. A
264    // 64-bit collision between two live tweens is not a practical concern.
265    let mut h = std::hash::DefaultHasher::new();
266    path_hash(from, &mut h);
267    0xffu8.hash(&mut h);
268    path_hash(to, &mut h);
269    let key = h.finish();
270    CACHE.with(|c| {
271        let mut c = c.borrow_mut();
272        if let Some(pos) = c.iter().position(|(k, _)| *k == key) {
273            let entry = c.remove(pos);
274            c.push(entry.clone());
275            return entry.1;
276        }
277        let value = std::rc::Rc::new(align(from, to));
278        if c.len() >= CACHE_SIZE {
279            c.remove(0);
280        }
281        c.push((key, value.clone()));
282        value
283    })
284}
285
286impl Lerp for BezPath {
287    fn lerp(&self, to: &Self, t: f64) -> Self {
288        if t <= 0.0 {
289            return self.clone();
290        }
291        if t >= 1.0 {
292            return to.clone();
293        }
294        let pair = aligned(self, to);
295        let mut out = BezPath::new();
296        for (ra, rb) in pair.0.iter().zip(&pair.1) {
297            let run = Run {
298                segs: ra
299                    .segs
300                    .iter()
301                    .zip(&rb.segs)
302                    .map(|(x, y)| {
303                        CubicBez::new(
304                            x.p0.lerp(y.p0, t),
305                            x.p1.lerp(y.p1, t),
306                            x.p2.lerp(y.p2, t),
307                            x.p3.lerp(y.p3, t),
308                        )
309                    })
310                    .collect(),
311                closed: ra.closed,
312            };
313            run.to_path(&mut out);
314        }
315        out
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use kurbo::{Circle, Rect, Shape};
322
323    use super::*;
324
325    fn sample(p: &BezPath, n: usize) -> Vec<Point> {
326        let segs: Vec<_> = p.segments().collect();
327        (0..n)
328            .map(|i| {
329                let u = i as f64 / n as f64 * segs.len() as f64;
330                segs[(u as usize).min(segs.len() - 1)].eval(u.fract())
331            })
332            .collect()
333    }
334
335    #[test]
336    fn endpoints_are_exact() {
337        let a = Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
338        let b = Circle::new((5.0, 5.0), 5.0).to_path(0.1);
339        assert_eq!(a.lerp(&b, 0.0), a);
340        assert_eq!(a.lerp(&b, 1.0), b);
341    }
342
343    #[test]
344    fn midpoint_of_identical_shapes_is_the_shape() {
345        let a = Circle::new((0.0, 0.0), 10.0).to_path(0.1);
346        let mid = a.lerp(&a, 0.5);
347        for (p, q) in sample(&mid, 32).iter().zip(sample(&a, 32)) {
348            assert!(p.distance(q) < 1e-9);
349        }
350    }
351
352    #[test]
353    fn rect_to_circle_stays_between_them() {
354        let a = Rect::new(-10.0, -10.0, 10.0, 10.0).to_path(0.1);
355        let b = Circle::new((0.0, 0.0), 10.0).to_path(0.1);
356        let mid = a.lerp(&b, 0.5);
357        // Every point lies between the inscribed circle and the square's
358        // corner radius: no self-intersection, no collapse.
359        for p in sample(&mid, 64) {
360            let r = p.to_vec2().hypot();
361            assert!((9.0..=14.2).contains(&r), "{p:?} r={r}");
362        }
363    }
364
365    #[test]
366    fn rotation_and_direction_are_aligned() {
367        // Same square, different start corner and opposite direction:
368        // the morph must not spin or pass through the center.
369        let a = Rect::new(-10.0, -10.0, 10.0, 10.0).to_path(0.1);
370        let mut b = BezPath::new();
371        b.move_to((10.0, 10.0));
372        b.line_to((10.0, -10.0));
373        b.line_to((-10.0, -10.0));
374        b.line_to((-10.0, 10.0));
375        b.close_path();
376        let mid = a.lerp(&b, 0.5);
377        for p in sample(&mid, 16) {
378            assert!(p.x.abs().max(p.y.abs()) > 9.99, "{p:?}");
379        }
380    }
381
382    #[test]
383    fn different_run_counts_grow_from_points() {
384        let a = Circle::new((0.0, 0.0), 10.0).to_path(0.1);
385        let mut b = Circle::new((-20.0, 0.0), 5.0).to_path(0.1);
386        b.extend(Circle::new((20.0, 0.0), 5.0).to_path(0.1));
387        let mid = a.lerp(&b, 0.5);
388        assert_eq!(
389            mid.elements()
390                .iter()
391                .filter(|e| matches!(e, PathEl::MoveTo(_)))
392                .count(),
393            2
394        );
395    }
396}