Skip to main content

rust_motion_paths/
interpolate.rs

1//! SVG path interpolation and length approximation utilities.
2//!
3//! Ported from Remotion's `@remotion/paths` helpers:
4//! - [`evolve_path`] — draw-on animation via stroke-dash trick
5//! - [`approximate_path_length`] — fast string-based path length estimation
6//! - [`interpolate_path`] — linear interpolation between two SVG paths
7
8pub use crate::evolve_path::{evolve_path, evolve_path_with_length};
9
10// ---------------------------------------------------------------------------
11// approximate_path_length
12// ---------------------------------------------------------------------------
13
14/// Computes an approximation of the total length of an SVG `d` path string.
15///
16/// Handles absolute and relative: `M`/`m`, `L`/`l`, `H`/`h`, `V`/`v`,
17/// `C`/`c`, `Q`/`q`, `A`/`a`, `Z`/`z`.  Bezier curves and arcs are approximated
18/// accurately.
19pub fn approximate_path_length(path: &str) -> f64 {
20    let tokens = tokenize_path(path);
21    let mut idx = 0;
22    let mut total = 0.0;
23    let mut cx = 0.0f64;
24    let mut cy = 0.0f64;
25    let mut start_x = 0.0f64;
26    let mut start_y = 0.0f64;
27
28    while idx < tokens.len() {
29        let cmd = tokens[idx].as_str();
30        idx += 1;
31
32        match cmd {
33            "M" => {
34                let x = next_f64(&tokens, &mut idx);
35                let y = next_f64(&tokens, &mut idx);
36                cx = x;
37                cy = y;
38                start_x = cx;
39                start_y = cy;
40                while idx < tokens.len() && is_number(&tokens[idx]) {
41                    let x2 = next_f64(&tokens, &mut idx);
42                    let y2 = next_f64(&tokens, &mut idx);
43                    total += dist(cx, cy, x2, y2);
44                    cx = x2;
45                    cy = y2;
46                }
47            }
48            "m" => {
49                let dx = next_f64(&tokens, &mut idx);
50                let dy = next_f64(&tokens, &mut idx);
51                cx += dx;
52                cy += dy;
53                start_x = cx;
54                start_y = cy;
55                while idx < tokens.len() && is_number(&tokens[idx]) {
56                    let dx2 = next_f64(&tokens, &mut idx);
57                    let dy2 = next_f64(&tokens, &mut idx);
58                    total += dist(cx, cy, cx + dx2, cy + dy2);
59                    cx += dx2;
60                    cy += dy2;
61                }
62            }
63            "L" => {
64                while idx < tokens.len() && is_number(&tokens[idx]) {
65                    let x = next_f64(&tokens, &mut idx);
66                    let y = next_f64(&tokens, &mut idx);
67                    total += dist(cx, cy, x, y);
68                    cx = x;
69                    cy = y;
70                }
71            }
72            "l" => {
73                while idx < tokens.len() && is_number(&tokens[idx]) {
74                    let dx = next_f64(&tokens, &mut idx);
75                    let dy = next_f64(&tokens, &mut idx);
76                    total += dist(0.0, 0.0, dx, dy);
77                    cx += dx;
78                    cy += dy;
79                }
80            }
81            "H" => {
82                while idx < tokens.len() && is_number(&tokens[idx]) {
83                    let x = next_f64(&tokens, &mut idx);
84                    total += (x - cx).abs();
85                    cx = x;
86                }
87            }
88            "h" => {
89                while idx < tokens.len() && is_number(&tokens[idx]) {
90                    let dx = next_f64(&tokens, &mut idx);
91                    total += dx.abs();
92                    cx += dx;
93                }
94            }
95            "V" => {
96                while idx < tokens.len() && is_number(&tokens[idx]) {
97                    let y = next_f64(&tokens, &mut idx);
98                    total += (y - cy).abs();
99                    cy = y;
100                }
101            }
102            "v" => {
103                while idx < tokens.len() && is_number(&tokens[idx]) {
104                    let dy = next_f64(&tokens, &mut idx);
105                    total += dy.abs();
106                    cy += dy;
107                }
108            }
109            "C" => {
110                while idx < tokens.len() && is_number(&tokens[idx]) {
111                    let x1 = next_f64(&tokens, &mut idx);
112                    let y1 = next_f64(&tokens, &mut idx);
113                    let x2 = next_f64(&tokens, &mut idx);
114                    let y2 = next_f64(&tokens, &mut idx);
115                    let x = next_f64(&tokens, &mut idx);
116                    let y = next_f64(&tokens, &mut idx);
117                    total += cubic_len((cx, cy), (x1, y1), (x2, y2), (x, y));
118                    cx = x;
119                    cy = y;
120                }
121            }
122            "c" => {
123                while idx < tokens.len() && is_number(&tokens[idx]) {
124                    let dx1 = next_f64(&tokens, &mut idx);
125                    let dy1 = next_f64(&tokens, &mut idx);
126                    let dx2 = next_f64(&tokens, &mut idx);
127                    let dy2 = next_f64(&tokens, &mut idx);
128                    let dx = next_f64(&tokens, &mut idx);
129                    let dy = next_f64(&tokens, &mut idx);
130                    total += cubic_len(
131                        (cx, cy),
132                        (cx + dx1, cy + dy1),
133                        (cx + dx2, cy + dy2),
134                        (cx + dx, cy + dy),
135                    );
136                    cx += dx;
137                    cy += dy;
138                }
139            }
140            "Q" => {
141                while idx < tokens.len() && is_number(&tokens[idx]) {
142                    let x1 = next_f64(&tokens, &mut idx);
143                    let y1 = next_f64(&tokens, &mut idx);
144                    let x = next_f64(&tokens, &mut idx);
145                    let y = next_f64(&tokens, &mut idx);
146                    total += quad_len(cx, cy, x1, y1, x, y);
147                    cx = x;
148                    cy = y;
149                }
150            }
151            "q" => {
152                while idx < tokens.len() && is_number(&tokens[idx]) {
153                    let dx1 = next_f64(&tokens, &mut idx);
154                    let dy1 = next_f64(&tokens, &mut idx);
155                    let dx = next_f64(&tokens, &mut idx);
156                    let dy = next_f64(&tokens, &mut idx);
157                    total += quad_len(cx, cy, cx + dx1, cy + dy1, cx + dx, cy + dy);
158                    cx += dx;
159                    cy += dy;
160                }
161            }
162            "A" => {
163                while idx < tokens.len() && is_number(&tokens[idx]) {
164                    let rx = next_f64(&tokens, &mut idx);
165                    let ry = next_f64(&tokens, &mut idx);
166                    let x_axis_rotation = next_f64(&tokens, &mut idx);
167                    let large_arc_flag = next_f64(&tokens, &mut idx) != 0.0;
168                    let sweep_flag = next_f64(&tokens, &mut idx) != 0.0;
169                    let x = next_f64(&tokens, &mut idx);
170                    let y = next_f64(&tokens, &mut idx);
171                    total += crate::length::arc_segment_length(
172                        (cx, cy),
173                        (x, y),
174                        rx,
175                        ry,
176                        x_axis_rotation,
177                        large_arc_flag,
178                        sweep_flag,
179                    );
180                    cx = x;
181                    cy = y;
182                }
183            }
184            "a" => {
185                while idx < tokens.len() && is_number(&tokens[idx]) {
186                    let rx = next_f64(&tokens, &mut idx);
187                    let ry = next_f64(&tokens, &mut idx);
188                    let x_axis_rotation = next_f64(&tokens, &mut idx);
189                    let large_arc_flag = next_f64(&tokens, &mut idx) != 0.0;
190                    let sweep_flag = next_f64(&tokens, &mut idx) != 0.0;
191                    let dx = next_f64(&tokens, &mut idx);
192                    let dy = next_f64(&tokens, &mut idx);
193                    let x = cx + dx;
194                    let y = cy + dy;
195                    total += crate::length::arc_segment_length(
196                        (cx, cy),
197                        (x, y),
198                        rx,
199                        ry,
200                        x_axis_rotation,
201                        large_arc_flag,
202                        sweep_flag,
203                    );
204                    cx = x;
205                    cy = y;
206                }
207            }
208            "Z" | "z" => {
209                total += dist(cx, cy, start_x, start_y);
210                cx = start_x;
211                cy = start_y;
212            }
213            _ => {}
214        }
215    }
216
217    total
218}
219
220// ---------------------------------------------------------------------------
221// interpolate_path
222// ---------------------------------------------------------------------------
223
224/// Linearly interpolates between two SVG path strings.
225///
226/// Both paths should have the same command structure (same number of numeric
227/// arguments).  If the numeric argument count differs the function falls back
228/// to returning `from` when `progress < 0.5` and `to` otherwise.
229pub fn interpolate_path(from: &str, to: &str, progress: f64) -> String {
230    let t = progress.clamp(0.0, 1.0);
231    let (from_nums, template) = extract_nums_and_template(from);
232    let (to_nums, _) = extract_nums_and_template(to);
233
234    if from_nums.len() != to_nums.len() {
235        return if t < 0.5 {
236            from.to_string()
237        } else {
238            to.to_string()
239        };
240    }
241
242    let interp: Vec<f64> = from_nums
243        .iter()
244        .zip(to_nums.iter())
245        .map(|(a, b)| a * (1.0 - t) + b * t)
246        .collect();
247
248    reconstruct(&template, &interp)
249}
250
251// ---------------------------------------------------------------------------
252// Internal helpers
253// ---------------------------------------------------------------------------
254
255fn tokenize_path(d: &str) -> Vec<String> {
256    let mut tokens: Vec<String> = Vec::new();
257    let mut current = String::new();
258    for c in d.chars() {
259        if c.is_ascii_alphabetic() && c != 'e' && c != 'E' {
260            if !current.trim().is_empty() {
261                tokens.push(current.trim().to_string());
262                current.clear();
263            }
264            tokens.push(c.to_string());
265        } else if c == ',' || c.is_whitespace() {
266            if !current.trim().is_empty() {
267                tokens.push(current.trim().to_string());
268                current.clear();
269            }
270        } else if ((c == '-' || c == '+')
271            && !current.is_empty()
272            && !current.ends_with('e')
273            && !current.ends_with('E'))
274            || (c == '.' && current.contains('.'))
275        {
276            tokens.push(current.trim().to_string());
277            current.clear();
278            current.push(c);
279        } else {
280            current.push(c);
281        }
282    }
283    if !current.trim().is_empty() {
284        tokens.push(current.trim().to_string());
285    }
286    tokens
287}
288
289fn is_number(s: &str) -> bool {
290    s.parse::<f64>().is_ok()
291}
292
293fn next_f64(tokens: &[String], idx: &mut usize) -> f64 {
294    if *idx < tokens.len() {
295        let v = tokens[*idx].parse::<f64>().unwrap_or(0.0);
296        *idx += 1;
297        v
298    } else {
299        0.0
300    }
301}
302
303fn dist(x0: f64, y0: f64, x1: f64, y1: f64) -> f64 {
304    let dx = x1 - x0;
305    let dy = y1 - y0;
306    (dx * dx + dy * dy).sqrt()
307}
308
309fn cubic_len(
310    (x0, y0): (f64, f64),
311    (x1, y1): (f64, f64),
312    (x2, y2): (f64, f64),
313    (x3, y3): (f64, f64),
314) -> f64 {
315    const N: usize = 10;
316    let (mut len, mut px, mut py) = (0.0, x0, y0);
317    for i in 1..=N {
318        let t = i as f64 / N as f64;
319        let mt = 1.0 - t;
320        let qx =
321            mt * mt * mt * x0 + 3.0 * mt * mt * t * x1 + 3.0 * mt * t * t * x2 + t * t * t * x3;
322        let qy =
323            mt * mt * mt * y0 + 3.0 * mt * mt * t * y1 + 3.0 * mt * t * t * y2 + t * t * t * y3;
324        len += dist(px, py, qx, qy);
325        px = qx;
326        py = qy;
327    }
328    len
329}
330
331fn quad_len(x0: f64, y0: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
332    const N: usize = 10;
333    let (mut len, mut px, mut py) = (0.0, x0, y0);
334    for i in 1..=N {
335        let t = i as f64 / N as f64;
336        let mt = 1.0 - t;
337        let qx = mt * mt * x0 + 2.0 * mt * t * x1 + t * t * x2;
338        let qy = mt * mt * y0 + 2.0 * mt * t * y1 + t * t * y2;
339        len += dist(px, py, qx, qy);
340        px = qx;
341        py = qy;
342    }
343    len
344}
345
346// ---------------------------------------------------------------------------
347// Template extraction / reconstruction for interpolate_path
348// ---------------------------------------------------------------------------
349
350enum Segment {
351    Literal(String),
352    Number,
353}
354
355fn extract_nums_and_template(path: &str) -> (Vec<f64>, Vec<Segment>) {
356    let mut nums: Vec<f64> = Vec::new();
357    let mut template: Vec<Segment> = Vec::new();
358    let mut literal = String::new();
359    let chars: Vec<char> = path.chars().collect();
360    let mut i = 0;
361
362    while i < chars.len() {
363        let c = chars[i];
364        let is_num_start = c.is_ascii_digit()
365            || (c == '.' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit())
366            || (c == '-'
367                && i + 1 < chars.len()
368                && (chars[i + 1].is_ascii_digit() || chars[i + 1] == '.')
369                // only treat '-' as number start if preceded by non-digit
370                && (literal.is_empty()
371                    || !literal.ends_with(|ch: char| ch.is_ascii_digit() || ch == '.')));
372        if is_num_start {
373            if !literal.is_empty() {
374                template.push(Segment::Literal(literal.clone()));
375                literal.clear();
376            }
377            let start = i;
378            if chars[i] == '-' {
379                i += 1;
380            }
381            while i < chars.len() && chars[i].is_ascii_digit() {
382                i += 1;
383            }
384            if i < chars.len() && chars[i] == '.' {
385                i += 1;
386                while i < chars.len() && chars[i].is_ascii_digit() {
387                    i += 1;
388                }
389            }
390            if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
391                i += 1;
392                if i < chars.len() && (chars[i] == '+' || chars[i] == '-') {
393                    i += 1;
394                }
395                while i < chars.len() && chars[i].is_ascii_digit() {
396                    i += 1;
397                }
398            }
399            let num_str: String = chars[start..i].iter().collect();
400            if let Ok(v) = num_str.parse::<f64>() {
401                nums.push(v);
402                template.push(Segment::Number);
403            } else {
404                template.push(Segment::Literal(num_str));
405            }
406        } else {
407            literal.push(c);
408            i += 1;
409        }
410    }
411    if !literal.is_empty() {
412        template.push(Segment::Literal(literal));
413    }
414    (nums, template)
415}
416
417fn reconstruct(template: &[Segment], numbers: &[f64]) -> String {
418    let mut out = String::new();
419    let mut ni = 0;
420    for seg in template {
421        match seg {
422            Segment::Literal(s) => out.push_str(s),
423            Segment::Number => {
424                if ni < numbers.len() {
425                    let v = numbers[ni];
426                    if v.fract() == 0.0 && v.abs() < 1e10 {
427                        out.push_str(&format!("{}", v as i64));
428                    } else {
429                        out.push_str(&format!("{:.4}", v));
430                    }
431                    ni += 1;
432                }
433            }
434        }
435    }
436    out
437}
438
439// ---------------------------------------------------------------------------
440// Tests
441// ---------------------------------------------------------------------------
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use std::f64::consts::PI;
446
447    #[test]
448    fn approximate_path_length_line() {
449        // 3-4-5 right triangle
450        let len = approximate_path_length("M 0 0 L 3 4");
451        assert!((len - 5.0).abs() < 0.01, "expected ~5, got {len}");
452    }
453
454    #[test]
455    fn approximate_path_length_rect() {
456        let len = approximate_path_length("M 0 0 L 100 0 L 100 100 L 0 100 Z");
457        assert!((len - 400.0).abs() < 0.1, "expected ~400, got {len}");
458    }
459
460    #[test]
461    fn approximate_path_length_h_v() {
462        let len = approximate_path_length("M 0 0 H 50 V 50");
463        assert!((len - 100.0).abs() < 0.01, "expected ~100, got {len}");
464    }
465
466    #[test]
467    fn approximate_path_length_arc() {
468        let len = approximate_path_length("M 100 0 A 100 100 0 0 1 0 100");
469        let expected = 0.5 * PI * 100.0;
470        assert!(
471            (len - expected).abs() < 0.5,
472            "expected ~{expected}, got {len}"
473        );
474    }
475
476    #[test]
477    fn interpolate_path_midpoint() {
478        let from = "M 0 0 L 100 0";
479        let to = "M 0 0 L 0 100";
480        let mid = interpolate_path(from, to, 0.5);
481        assert!(mid.contains("50"), "mid path should contain 50: {mid}");
482    }
483
484    #[test]
485    fn interpolate_path_incompatible_falls_back() {
486        let from = "M 0 0 L 10 0";
487        let to = "M 0 0 L 10 0 L 20 0";
488        let r_lo = interpolate_path(from, to, 0.3);
489        let r_hi = interpolate_path(from, to, 0.7);
490        assert_eq!(r_lo, from);
491        assert_eq!(r_hi, to);
492    }
493
494    #[test]
495    fn interpolate_path_endpoints() {
496        let from = "M 10 20 L 30 40";
497        let to = "M 50 60 L 70 80";
498        assert_eq!(interpolate_path(from, to, 0.0), from);
499        assert_eq!(interpolate_path(from, to, 1.0), to);
500    }
501
502    #[test]
503    fn interpolate_path_beziers_and_negative() {
504        let from = "M -10 -20 C 0 10 20 30 40 50";
505        let to = "M 10 20 C 20 30 40 50 60 70";
506        let mid = interpolate_path(from, to, 0.5);
507        assert_eq!(mid, "M 0 0 C 10 20 30 40 50 60");
508    }
509
510    #[test]
511    fn approximate_path_length_parity_with_get_length() {
512        let path = "M 0 0 L 100 0 L 100 100 A 50 50 0 0 1 50 150 Z";
513        let approx = approximate_path_length(path);
514        let actual = crate::length::get_length(path);
515        assert!(
516            (approx - actual).abs() < 1.0,
517            "approx {approx} vs actual {actual}"
518        );
519    }
520}