471 lines
15 KiB
Rust
471 lines
15 KiB
Rust
//! Path interpolation using the d3-interpolate-path algorithm
|
|
//!
|
|
//! This module implements path morphing by normalizing two paths to have
|
|
//! the same number of segments and then interpolating between them.
|
|
//!
|
|
//! Based on: https://github.com/pbeshai/d3-interpolate-path
|
|
|
|
use kurbo::{BezPath, PathEl, Point};
|
|
|
|
/// de Casteljau's algorithm for splitting bezier curves
|
|
///
|
|
/// Takes a list of control points and a parameter t, and returns
|
|
/// the two curves (left and right) that result from splitting at t.
|
|
fn decasteljau(points: &[Point], t: f64) -> (Vec<Point>, Vec<Point>) {
|
|
let mut left = Vec::new();
|
|
let mut right = Vec::new();
|
|
|
|
fn recurse(points: &[Point], t: f64, left: &mut Vec<Point>, right: &mut Vec<Point>) {
|
|
if points.len() == 1 {
|
|
left.push(points[0]);
|
|
right.push(points[0]);
|
|
} else {
|
|
let mut new_points = Vec::with_capacity(points.len() - 1);
|
|
|
|
for i in 0..points.len() - 1 {
|
|
if i == 0 {
|
|
left.push(points[0]);
|
|
}
|
|
if i == points.len() - 2 {
|
|
right.push(points[i + 1]);
|
|
}
|
|
|
|
// Linear interpolation between consecutive points
|
|
let x = (1.0 - t) * points[i].x + t * points[i + 1].x;
|
|
let y = (1.0 - t) * points[i].y + t * points[i + 1].y;
|
|
new_points.push(Point::new(x, y));
|
|
}
|
|
|
|
recurse(&new_points, t, left, right);
|
|
}
|
|
}
|
|
|
|
if !points.is_empty() {
|
|
recurse(points, t, &mut left, &mut right);
|
|
right.reverse();
|
|
}
|
|
|
|
(left, right)
|
|
}
|
|
|
|
/// A simplified path command representation for interpolation
|
|
#[derive(Clone, Debug)]
|
|
enum PathCommand {
|
|
MoveTo { x: f64, y: f64 },
|
|
LineTo { x: f64, y: f64 },
|
|
QuadTo { x1: f64, y1: f64, x: f64, y: f64 },
|
|
CurveTo { x1: f64, y1: f64, x2: f64, y2: f64, x: f64, y: f64 },
|
|
Close,
|
|
}
|
|
|
|
impl PathCommand {
|
|
/// Get the end point of this command
|
|
fn end_point(&self) -> Point {
|
|
match self {
|
|
PathCommand::MoveTo { x, y }
|
|
| PathCommand::LineTo { x, y }
|
|
| PathCommand::QuadTo { x, y, .. }
|
|
| PathCommand::CurveTo { x, y, .. } => Point::new(*x, *y),
|
|
PathCommand::Close => Point::new(0.0, 0.0), // Will be handled specially
|
|
}
|
|
}
|
|
|
|
/// Get all control points for this command (from start point)
|
|
fn to_points(&self, start: Point) -> Vec<Point> {
|
|
match self {
|
|
PathCommand::LineTo { x, y } => {
|
|
vec![start, Point::new(*x, *y)]
|
|
}
|
|
PathCommand::QuadTo { x1, y1, x, y } => {
|
|
vec![start, Point::new(*x1, *y1), Point::new(*x, *y)]
|
|
}
|
|
PathCommand::CurveTo { x1, y1, x2, y2, x, y } => {
|
|
vec![
|
|
start,
|
|
Point::new(*x1, *y1),
|
|
Point::new(*x2, *y2),
|
|
Point::new(*x, *y),
|
|
]
|
|
}
|
|
_ => vec![start],
|
|
}
|
|
}
|
|
|
|
/// Convert command type to match another command
|
|
fn convert_to_type(&self, target: &PathCommand) -> PathCommand {
|
|
match target {
|
|
PathCommand::CurveTo { .. } => {
|
|
// Convert to cubic curve
|
|
let end = self.end_point();
|
|
match self {
|
|
PathCommand::LineTo { .. } | PathCommand::MoveTo { .. } => {
|
|
PathCommand::CurveTo {
|
|
x1: end.x,
|
|
y1: end.y,
|
|
x2: end.x,
|
|
y2: end.y,
|
|
x: end.x,
|
|
y: end.y,
|
|
}
|
|
}
|
|
PathCommand::QuadTo { x1, y1, x, y } => {
|
|
// Convert quadratic to cubic
|
|
PathCommand::CurveTo {
|
|
x1: *x1,
|
|
y1: *y1,
|
|
x2: *x1,
|
|
y2: *y1,
|
|
x: *x,
|
|
y: *y,
|
|
}
|
|
}
|
|
PathCommand::CurveTo { .. } => self.clone(),
|
|
PathCommand::Close => self.clone(),
|
|
}
|
|
}
|
|
PathCommand::QuadTo { .. } => {
|
|
// Convert to quadratic curve
|
|
let end = self.end_point();
|
|
match self {
|
|
PathCommand::LineTo { .. } | PathCommand::MoveTo { .. } => {
|
|
PathCommand::QuadTo {
|
|
x1: end.x,
|
|
y1: end.y,
|
|
x: end.x,
|
|
y: end.y,
|
|
}
|
|
}
|
|
PathCommand::QuadTo { .. } => self.clone(),
|
|
PathCommand::CurveTo { x1, y1, x, y, .. } => {
|
|
// Use first control point for quad
|
|
PathCommand::QuadTo {
|
|
x1: *x1,
|
|
y1: *y1,
|
|
x: *x,
|
|
y: *y,
|
|
}
|
|
}
|
|
PathCommand::Close => self.clone(),
|
|
}
|
|
}
|
|
PathCommand::LineTo { .. } => {
|
|
let end = self.end_point();
|
|
PathCommand::LineTo { x: end.x, y: end.y }
|
|
}
|
|
_ => self.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert points back to a command
|
|
fn points_to_command(points: &[Point]) -> PathCommand {
|
|
match points.len() {
|
|
2 => PathCommand::LineTo {
|
|
x: points[1].x,
|
|
y: points[1].y,
|
|
},
|
|
3 => PathCommand::QuadTo {
|
|
x1: points[1].x,
|
|
y1: points[1].y,
|
|
x: points[2].x,
|
|
y: points[2].y,
|
|
},
|
|
4 => PathCommand::CurveTo {
|
|
x1: points[1].x,
|
|
y1: points[1].y,
|
|
x2: points[2].x,
|
|
y2: points[2].y,
|
|
x: points[3].x,
|
|
y: points[3].y,
|
|
},
|
|
_ => PathCommand::LineTo {
|
|
x: points.last().map(|p| p.x).unwrap_or(0.0),
|
|
y: points.last().map(|p| p.y).unwrap_or(0.0),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Split a curve segment into multiple segments using de Casteljau
|
|
fn split_segment(start: Point, command: &PathCommand, count: usize) -> Vec<PathCommand> {
|
|
if count == 0 {
|
|
return vec![];
|
|
}
|
|
if count == 1 {
|
|
return vec![command.clone()];
|
|
}
|
|
|
|
// For splittable curves (L, Q, C), use de Casteljau
|
|
match command {
|
|
PathCommand::LineTo { .. }
|
|
| PathCommand::QuadTo { .. }
|
|
| PathCommand::CurveTo { .. } => {
|
|
let points = command.to_points(start);
|
|
split_curve_as_points(&points, count)
|
|
.into_iter()
|
|
.map(|pts| points_to_command(&pts))
|
|
.collect()
|
|
}
|
|
_ => {
|
|
// For other commands, just repeat
|
|
vec![command.clone(); count]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Split a curve (represented as points) into segment_count segments
|
|
fn split_curve_as_points(points: &[Point], segment_count: usize) -> Vec<Vec<Point>> {
|
|
let mut segments = Vec::new();
|
|
let mut remaining_curve = points.to_vec();
|
|
let t_increment = 1.0 / segment_count as f64;
|
|
|
|
for i in 0..segment_count - 1 {
|
|
let t_relative = t_increment / (1.0 - t_increment * i as f64);
|
|
let (left, right) = decasteljau(&remaining_curve, t_relative);
|
|
segments.push(left);
|
|
remaining_curve = right;
|
|
}
|
|
|
|
segments.push(remaining_curve);
|
|
segments
|
|
}
|
|
|
|
/// Extend a path to match the length of a reference path
|
|
fn extend_commands(
|
|
commands_to_extend: &[PathCommand],
|
|
reference_commands: &[PathCommand],
|
|
) -> Vec<PathCommand> {
|
|
if commands_to_extend.is_empty() || reference_commands.is_empty() {
|
|
return commands_to_extend.to_vec();
|
|
}
|
|
|
|
let num_segments_to_extend = commands_to_extend.len() - 1;
|
|
let num_reference_segments = reference_commands.len() - 1;
|
|
|
|
if num_reference_segments == 0 {
|
|
return commands_to_extend.to_vec();
|
|
}
|
|
|
|
let segment_ratio = num_segments_to_extend as f64 / num_reference_segments as f64;
|
|
|
|
// Count how many points should be in each segment
|
|
let mut count_per_segment = vec![0; num_segments_to_extend];
|
|
for i in 0..num_reference_segments {
|
|
let insert_index = ((segment_ratio * i as f64).floor() as usize)
|
|
.min(num_segments_to_extend.saturating_sub(1));
|
|
count_per_segment[insert_index] += 1;
|
|
}
|
|
|
|
// Start with first command
|
|
let mut extended = vec![commands_to_extend[0].clone()];
|
|
let mut current_point = commands_to_extend[0].end_point();
|
|
|
|
// Extend each segment
|
|
for (i, &count) in count_per_segment.iter().enumerate() {
|
|
if i >= commands_to_extend.len() - 1 {
|
|
// Handle last command
|
|
for _ in 0..count {
|
|
extended.push(commands_to_extend[commands_to_extend.len() - 1].clone());
|
|
}
|
|
} else {
|
|
// Split this segment
|
|
let split_commands =
|
|
split_segment(current_point, &commands_to_extend[i + 1], count.max(1));
|
|
extended.extend(split_commands);
|
|
current_point = commands_to_extend[i + 1].end_point();
|
|
}
|
|
}
|
|
|
|
extended
|
|
}
|
|
|
|
/// Convert a BezPath to our internal command representation
|
|
fn bezpath_to_commands(path: &BezPath) -> Vec<PathCommand> {
|
|
let mut commands = Vec::new();
|
|
|
|
for el in path.elements() {
|
|
match el {
|
|
PathEl::MoveTo(p) => {
|
|
commands.push(PathCommand::MoveTo { x: p.x, y: p.y });
|
|
}
|
|
PathEl::LineTo(p) => {
|
|
commands.push(PathCommand::LineTo { x: p.x, y: p.y });
|
|
}
|
|
PathEl::QuadTo(p1, p2) => {
|
|
commands.push(PathCommand::QuadTo {
|
|
x1: p1.x,
|
|
y1: p1.y,
|
|
x: p2.x,
|
|
y: p2.y,
|
|
});
|
|
}
|
|
PathEl::CurveTo(p1, p2, p3) => {
|
|
commands.push(PathCommand::CurveTo {
|
|
x1: p1.x,
|
|
y1: p1.y,
|
|
x2: p2.x,
|
|
y2: p2.y,
|
|
x: p3.x,
|
|
y: p3.y,
|
|
});
|
|
}
|
|
PathEl::ClosePath => {
|
|
commands.push(PathCommand::Close);
|
|
}
|
|
}
|
|
}
|
|
|
|
commands
|
|
}
|
|
|
|
/// Convert our internal commands back to a BezPath
|
|
fn commands_to_bezpath(commands: &[PathCommand]) -> BezPath {
|
|
let mut path = BezPath::new();
|
|
|
|
for cmd in commands {
|
|
match cmd {
|
|
PathCommand::MoveTo { x, y } => {
|
|
path.move_to((*x, *y));
|
|
}
|
|
PathCommand::LineTo { x, y } => {
|
|
path.line_to((*x, *y));
|
|
}
|
|
PathCommand::QuadTo { x1, y1, x, y } => {
|
|
path.quad_to((*x1, *y1), (*x, *y));
|
|
}
|
|
PathCommand::CurveTo { x1, y1, x2, y2, x, y } => {
|
|
path.curve_to((*x1, *y1), (*x2, *y2), (*x, *y));
|
|
}
|
|
PathCommand::Close => {
|
|
path.close_path();
|
|
}
|
|
}
|
|
}
|
|
|
|
path
|
|
}
|
|
|
|
/// Interpolate between two paths at parameter t (0.0 to 1.0)
|
|
///
|
|
/// Uses the d3-interpolate-path algorithm:
|
|
/// 1. Normalize paths to same length by splitting segments
|
|
/// 2. Convert commands to matching types
|
|
/// 3. Linearly interpolate all parameters
|
|
pub fn interpolate_paths(path_a: &BezPath, path_b: &BezPath, t: f64) -> BezPath {
|
|
let mut commands_a = bezpath_to_commands(path_a);
|
|
let mut commands_b = bezpath_to_commands(path_b);
|
|
|
|
// Handle Z (close path) - remove temporarily, add back if both have it
|
|
let add_z = commands_a.last().map_or(false, |c| matches!(c, PathCommand::Close))
|
|
&& commands_b.last().map_or(false, |c| matches!(c, PathCommand::Close));
|
|
|
|
if commands_a.last().map_or(false, |c| matches!(c, PathCommand::Close)) {
|
|
commands_a.pop();
|
|
}
|
|
if commands_b.last().map_or(false, |c| matches!(c, PathCommand::Close)) {
|
|
commands_b.pop();
|
|
}
|
|
|
|
// Handle empty paths
|
|
if commands_a.is_empty() && !commands_b.is_empty() {
|
|
commands_a.push(commands_b[0].clone());
|
|
} else if commands_b.is_empty() && !commands_a.is_empty() {
|
|
commands_b.push(commands_a[0].clone());
|
|
} else if commands_a.is_empty() && commands_b.is_empty() {
|
|
return BezPath::new();
|
|
}
|
|
|
|
// Extend paths to match length
|
|
if commands_a.len() < commands_b.len() {
|
|
commands_a = extend_commands(&commands_a, &commands_b);
|
|
} else if commands_b.len() < commands_a.len() {
|
|
commands_b = extend_commands(&commands_b, &commands_a);
|
|
}
|
|
|
|
// Convert A commands to match B types
|
|
commands_a = commands_a
|
|
.iter()
|
|
.zip(commands_b.iter())
|
|
.map(|(a, b)| a.convert_to_type(b))
|
|
.collect();
|
|
|
|
// Interpolate
|
|
let mut interpolated = Vec::new();
|
|
for (cmd_a, cmd_b) in commands_a.iter().zip(commands_b.iter()) {
|
|
let interpolated_cmd = match (cmd_a, cmd_b) {
|
|
(PathCommand::MoveTo { x: x1, y: y1 }, PathCommand::MoveTo { x: x2, y: y2 }) => {
|
|
PathCommand::MoveTo {
|
|
x: x1 + t * (x2 - x1),
|
|
y: y1 + t * (y2 - y1),
|
|
}
|
|
}
|
|
(PathCommand::LineTo { x: x1, y: y1 }, PathCommand::LineTo { x: x2, y: y2 }) => {
|
|
PathCommand::LineTo {
|
|
x: x1 + t * (x2 - x1),
|
|
y: y1 + t * (y2 - y1),
|
|
}
|
|
}
|
|
(
|
|
PathCommand::QuadTo { x1: xa1, y1: ya1, x: x1, y: y1 },
|
|
PathCommand::QuadTo { x1: xa2, y1: ya2, x: x2, y: y2 },
|
|
) => PathCommand::QuadTo {
|
|
x1: xa1 + t * (xa2 - xa1),
|
|
y1: ya1 + t * (ya2 - ya1),
|
|
x: x1 + t * (x2 - x1),
|
|
y: y1 + t * (y2 - y1),
|
|
},
|
|
(
|
|
PathCommand::CurveTo { x1: xa1, y1: ya1, x2: xb1, y2: yb1, x: x1, y: y1 },
|
|
PathCommand::CurveTo { x1: xa2, y1: ya2, x2: xb2, y2: yb2, x: x2, y: y2 },
|
|
) => PathCommand::CurveTo {
|
|
x1: xa1 + t * (xa2 - xa1),
|
|
y1: ya1 + t * (ya2 - ya1),
|
|
x2: xb1 + t * (xb2 - xb1),
|
|
y2: yb1 + t * (yb2 - yb1),
|
|
x: x1 + t * (x2 - x1),
|
|
y: y1 + t * (y2 - y1),
|
|
},
|
|
(PathCommand::Close, PathCommand::Close) => PathCommand::Close,
|
|
_ => cmd_a.clone(), // Fallback
|
|
};
|
|
interpolated.push(interpolated_cmd);
|
|
}
|
|
|
|
if add_z {
|
|
interpolated.push(PathCommand::Close);
|
|
}
|
|
|
|
commands_to_bezpath(&interpolated)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use kurbo::{Circle, Shape};
|
|
|
|
#[test]
|
|
fn test_decasteljau() {
|
|
let points = vec![
|
|
Point::new(0.0, 0.0),
|
|
Point::new(50.0, 0.0),
|
|
Point::new(50.0, 50.0),
|
|
Point::new(100.0, 50.0),
|
|
];
|
|
|
|
let (left, right) = decasteljau(&points, 0.5);
|
|
assert_eq!(left.len(), 4);
|
|
assert_eq!(right.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_interpolate_circles() {
|
|
let circle1 = Circle::new((100.0, 100.0), 50.0);
|
|
let circle2 = Circle::new((100.0, 100.0), 100.0);
|
|
|
|
let path1 = circle1.to_path(0.1);
|
|
let path2 = circle2.to_path(0.1);
|
|
|
|
let interpolated = interpolate_paths(&path1, &path2, 0.5);
|
|
assert!(!interpolated.elements().is_empty());
|
|
}
|
|
}
|