use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Point {
pub x: f64,
pub y: f64,
}
impl Point {
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
pub fn lerp(self, other: Self, t: f64) -> Self {
Self {
x: self.x + (other.x - self.x) * t,
y: self.y + (other.y - self.y) * t,
}
}
pub fn distance(self, other: Self) -> f64 {
let dx = other.x - self.x;
let dy = other.y - self.y;
(dx * dx + dy * dy).sqrt()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PathCmd {
MoveTo(Point),
LineTo(Point),
QuadTo(Point, Point), CubicTo(Point, Point, Point), ArcTo {
rx: f64,
ry: f64,
rotation: f64,
large_arc: bool,
sweep: bool,
end: Point,
},
Close,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Path2D {
pub commands: Vec<PathCmd>,
}
impl Path2D {
pub fn new() -> Self {
Self {
commands: Vec::new(),
}
}
pub fn move_to(&mut self, x: f64, y: f64) -> &mut Self {
self.commands.push(PathCmd::MoveTo(Point::new(x, y)));
self
}
pub fn line_to(&mut self, x: f64, y: f64) -> &mut Self {
self.commands.push(PathCmd::LineTo(Point::new(x, y)));
self
}
pub fn quad_to(&mut self, cx: f64, cy: f64, x: f64, y: f64) -> &mut Self {
self.commands
.push(PathCmd::QuadTo(Point::new(cx, cy), Point::new(x, y)));
self
}
pub fn cubic_to(
&mut self,
c1x: f64,
c1y: f64,
c2x: f64,
c2y: f64,
x: f64,
y: f64,
) -> &mut Self {
self.commands.push(PathCmd::CubicTo(
Point::new(c1x, c1y),
Point::new(c2x, c2y),
Point::new(x, y),
));
self
}
pub fn close(&mut self) -> &mut Self {
self.commands.push(PathCmd::Close);
self
}
pub fn from_svg(d: &str) -> Self {
let mut path = Self::new();
let mut chars = d.chars().peekable();
let mut current = Point::new(0.0, 0.0);
let mut start = Point::new(0.0, 0.0);
let mut last_ctrl = None;
fn skip_ws_comma(chars: &mut std::iter::Peekable<std::str::Chars>) {
while let Some(&c) = chars.peek() {
if c == ' ' || c == ',' || c == '\t' || c == '\n' || c == '\r' {
chars.next();
} else {
break;
}
}
}
fn parse_num(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<f64> {
skip_ws_comma(chars);
let mut s = String::new();
if let Some(&c) = chars.peek() {
if c == '-' || c == '+' {
s.push(c);
chars.next();
}
}
let mut has_dot = false;
let mut has_e = false;
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
s.push(c);
chars.next();
} else if c == '.' && !has_dot {
has_dot = true;
s.push(c);
chars.next();
} else if (c == 'e' || c == 'E') && !has_e {
has_e = true;
s.push(c);
chars.next();
if let Some(&c2) = chars.peek() {
if c2 == '-' || c2 == '+' {
s.push(c2);
chars.next();
}
}
} else {
break;
}
}
s.parse().ok()
}
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == ',' {
chars.next();
continue;
}
if c.is_alphabetic() {
chars.next();
let is_relative = c.is_lowercase();
match c.to_uppercase().next().unwrap() {
'M' => {
while let Some(x) = parse_num(&mut chars) {
let y = parse_num(&mut chars).unwrap_or(0.0);
let p = if is_relative {
Point::new(current.x + x, current.y + y)
} else {
Point::new(x, y)
};
if path.commands.is_empty()
|| matches!(path.commands.last(), Some(PathCmd::Close))
{
path.move_to(p.x, p.y);
start = p;
} else {
path.line_to(p.x, p.y);
}
current = p;
skip_ws_comma(&mut chars);
}
}
'L' => {
while let Some(x) = parse_num(&mut chars) {
let y = parse_num(&mut chars).unwrap_or(0.0);
let p = if is_relative {
Point::new(current.x + x, current.y + y)
} else {
Point::new(x, y)
};
path.line_to(p.x, p.y);
current = p;
skip_ws_comma(&mut chars);
}
}
'H' => {
while let Some(x) = parse_num(&mut chars) {
let px = if is_relative { current.x + x } else { x };
path.line_to(px, current.y);
current.x = px;
skip_ws_comma(&mut chars);
}
}
'V' => {
while let Some(y) = parse_num(&mut chars) {
let py = if is_relative { current.y + y } else { y };
path.line_to(current.x, py);
current.y = py;
skip_ws_comma(&mut chars);
}
}
'C' => {
while let Some(c1x) = parse_num(&mut chars) {
let c1y = parse_num(&mut chars).unwrap_or(0.0);
let c2x = parse_num(&mut chars).unwrap_or(0.0);
let c2y = parse_num(&mut chars).unwrap_or(0.0);
let x = parse_num(&mut chars).unwrap_or(0.0);
let y = parse_num(&mut chars).unwrap_or(0.0);
let (p1, p2, p3) = if is_relative {
(
Point::new(current.x + c1x, current.y + c1y),
Point::new(current.x + c2x, current.y + c2y),
Point::new(current.x + x, current.y + y),
)
} else {
(Point::new(c1x, c1y), Point::new(c2x, c2y), Point::new(x, y))
};
path.cubic_to(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
last_ctrl = Some(p2);
current = p3;
skip_ws_comma(&mut chars);
}
}
'S' => {
while let Some(c2x) = parse_num(&mut chars) {
let c2y = parse_num(&mut chars).unwrap_or(0.0);
let x = parse_num(&mut chars).unwrap_or(0.0);
let y = parse_num(&mut chars).unwrap_or(0.0);
let c1 = if let Some(lc) = last_ctrl {
Point::new(2.0 * current.x - lc.x, 2.0 * current.y - lc.y)
} else {
current
};
let (p2, p3) = if is_relative {
(
Point::new(current.x + c2x, current.y + c2y),
Point::new(current.x + x, current.y + y),
)
} else {
(Point::new(c2x, c2y), Point::new(x, y))
};
path.cubic_to(c1.x, c1.y, p2.x, p2.y, p3.x, p3.y);
last_ctrl = Some(p2);
current = p3;
skip_ws_comma(&mut chars);
}
}
'Q' => {
while let Some(cx) = parse_num(&mut chars) {
let cy = parse_num(&mut chars).unwrap_or(0.0);
let x = parse_num(&mut chars).unwrap_or(0.0);
let y = parse_num(&mut chars).unwrap_or(0.0);
let (ctrl, end) = if is_relative {
(
Point::new(current.x + cx, current.y + cy),
Point::new(current.x + x, current.y + y),
)
} else {
(Point::new(cx, cy), Point::new(x, y))
};
path.quad_to(ctrl.x, ctrl.y, end.x, end.y);
last_ctrl = Some(ctrl);
current = end;
skip_ws_comma(&mut chars);
}
}
'T' => {
while let Some(x) = parse_num(&mut chars) {
let y = parse_num(&mut chars).unwrap_or(0.0);
let ctrl = if let Some(lc) = last_ctrl {
Point::new(2.0 * current.x - lc.x, 2.0 * current.y - lc.y)
} else {
current
};
let end = if is_relative {
Point::new(current.x + x, current.y + y)
} else {
Point::new(x, y)
};
path.quad_to(ctrl.x, ctrl.y, end.x, end.y);
last_ctrl = Some(ctrl);
current = end;
skip_ws_comma(&mut chars);
}
}
'Z' => {
path.close();
current = start;
last_ctrl = None;
}
_ => {}
}
if !matches!(c.to_uppercase().next().unwrap(), 'C' | 'S' | 'Q' | 'T') {
last_ctrl = None;
}
} else {
chars.next();
}
}
path
}
pub fn to_svg(&self) -> String {
let mut s = String::new();
for cmd in &self.commands {
if !s.is_empty() {
s.push(' ');
}
match cmd {
PathCmd::MoveTo(p) => s.push_str(&format!("M{},{}", p.x, p.y)),
PathCmd::LineTo(p) => s.push_str(&format!("L{},{}", p.x, p.y)),
PathCmd::QuadTo(c, p) => s.push_str(&format!("Q{},{} {},{}", c.x, c.y, p.x, p.y)),
PathCmd::CubicTo(c1, c2, p) => s.push_str(&format!(
"C{},{} {},{} {},{}",
c1.x, c1.y, c2.x, c2.y, p.x, p.y
)),
PathCmd::ArcTo {
rx,
ry,
rotation,
large_arc,
sweep,
end,
} => {
s.push_str(&format!(
"A{},{} {} {} {} {},{}",
rx,
ry,
rotation,
if *large_arc { 1 } else { 0 },
if *sweep { 1 } else { 0 },
end.x,
end.y,
));
}
PathCmd::Close => s.push('Z'),
}
}
s
}
pub fn bounds(&self) -> (Point, Point) {
let mut min = Point::new(f64::MAX, f64::MAX);
let mut max = Point::new(f64::MIN, f64::MIN);
let mut current = Point::new(0.0, 0.0);
for cmd in &self.commands {
let pts: Vec<Point> = match cmd {
PathCmd::MoveTo(p) | PathCmd::LineTo(p) => {
current = *p;
vec![*p]
}
PathCmd::QuadTo(c, p) => {
current = *p;
vec![*c, *p]
}
PathCmd::CubicTo(c1, c2, p) => {
current = *p;
vec![*c1, *c2, *p]
}
PathCmd::ArcTo { end, .. } => {
current = *end;
vec![*end]
}
PathCmd::Close => vec![current],
};
for p in pts {
min.x = min.x.min(p.x);
min.y = min.y.min(p.y);
max.x = max.x.max(p.x);
max.y = max.y.max(p.y);
}
}
(min, max)
}
pub fn flatten(&self, tolerance: f64) -> Vec<Point> {
let mut points = Vec::new();
let mut current = Point::new(0.0, 0.0);
for cmd in &self.commands {
match cmd {
PathCmd::MoveTo(p) => {
current = *p;
points.push(*p);
}
PathCmd::LineTo(p) => {
current = *p;
points.push(*p);
}
PathCmd::QuadTo(ctrl, end) => {
let steps = adaptive_steps(current.distance(*end), tolerance);
for i in 1..=steps {
let t = i as f64 / steps as f64;
let p = quad_bezier(current, *ctrl, *end, t);
points.push(p);
}
current = *end;
}
PathCmd::CubicTo(c1, c2, end) => {
let steps = adaptive_steps(current.distance(*end), tolerance);
for i in 1..=steps {
let t = i as f64 / steps as f64;
let p = cubic_bezier(current, *c1, *c2, *end, t);
points.push(p);
}
current = *end;
}
PathCmd::ArcTo { end, .. } => {
current = *end;
points.push(*end);
}
PathCmd::Close => {
if let Some(&first) = points.first() {
points.push(first);
current = first;
}
}
}
}
points
}
pub fn sample(&self, t: f64) -> (Point, f64) {
let flat = self.flatten(0.5);
if flat.len() < 2 {
return (Point::new(0.0, 0.0), 0.0);
}
let mut lengths = vec![0.0];
for i in 1..flat.len() {
lengths.push(lengths[i - 1] + flat[i - 1].distance(flat[i]));
}
let total = *lengths.last().unwrap();
if total < 1e-10 {
return (flat[0], 0.0);
}
let target = t.clamp(0.0, 1.0) * total;
let idx = lengths
.partition_point(|&l| l < target)
.min(flat.len() - 1)
.max(1);
let seg_start = lengths[idx - 1];
let seg_len = lengths[idx] - seg_start;
let local_t = if seg_len > 0.0 {
(target - seg_start) / seg_len
} else {
0.0
};
let p = flat[idx - 1].lerp(flat[idx], local_t);
let dx = flat[idx].x - flat[idx - 1].x;
let dy = flat[idx].y - flat[idx - 1].y;
let angle = dy.atan2(dx);
(p, angle)
}
pub fn length(&self) -> f64 {
let flat = self.flatten(0.5);
let mut total = 0.0;
for i in 1..flat.len() {
total += flat[i - 1].distance(flat[i]);
}
total
}
pub fn segment_count(&self) -> usize {
self.commands.len()
}
pub fn is_closed(&self) -> bool {
matches!(self.commands.last(), Some(PathCmd::Close))
}
}
fn quad_bezier(p0: Point, p1: Point, p2: Point, t: f64) -> Point {
let u = 1.0 - t;
Point {
x: u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x,
y: u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y,
}
}
fn cubic_bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
let u = 1.0 - t;
let uu = u * u;
let tt = t * t;
Point {
x: uu * u * p0.x + 3.0 * uu * t * p1.x + 3.0 * u * tt * p2.x + tt * t * p3.x,
y: uu * u * p0.y + 3.0 * uu * t * p1.y + 3.0 * u * tt * p2.y + tt * t * p3.y,
}
}
fn adaptive_steps(chord_len: f64, tolerance: f64) -> usize {
((chord_len / tolerance).ceil() as usize).clamp(4, 128)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_svg_line() {
let p = Path2D::from_svg("M0,0 L10,10 L20,0 Z");
assert_eq!(p.commands.len(), 4);
assert!(p.is_closed());
}
#[test]
fn parse_svg_cubic() {
let p = Path2D::from_svg("M0,0 C5,10 15,10 20,0");
assert_eq!(p.commands.len(), 2);
}
#[test]
fn roundtrip_svg() {
let original = "M0,0 L10,10 L20,0 Z";
let p = Path2D::from_svg(original);
let svg = p.to_svg();
assert!(svg.contains("M0,0"));
assert!(svg.contains("Z"));
}
#[test]
fn bounds() {
let p = Path2D::from_svg("M0,0 L10,10 L20,0 Z");
let (min, max) = p.bounds();
assert_eq!(min.x, 0.0);
assert_eq!(max.x, 20.0);
}
#[test]
fn sample_midpoint() {
let p = Path2D::from_svg("M0,0 L10,0");
let (pt, _angle) = p.sample(0.5);
assert!((pt.x - 5.0).abs() < 0.5);
assert!(pt.y.abs() < 0.1);
}
}