use super::corner_class::{angle_between_deg, classify_angle, Class};
use super::tolerance::DEGENERATE_EPS;
use crate::geometry::{Point, Vec2};
use crate::path::Path;
use crate::primitives::CornerRounding;
#[derive(Clone, Copy, PartialEq, Eq)]
enum StopKind {
Corner,
Other,
Endpoint,
}
#[derive(Clone, Copy)]
struct Cut {
p_back: Point,
c1: Point,
c2: Point,
p_fwd: Point,
degenerate: bool,
}
pub fn round_corners(vertices: &[Point], closed: bool, opts: CornerRounding) -> Path {
let n = vertices.len();
if n < 2 || (closed && n < 3) {
return Path::new();
}
let classes = classify(vertices, closed, opts.max_angle_deg);
let cuts = compute_cuts(vertices, &classes, closed, opts.max_cut);
emit(vertices, &classes, &cuts, closed)
}
fn classify(verts: &[Point], closed: bool, max_angle_deg: f64) -> Vec<Class> {
let n = verts.len();
(0..n)
.map(|i| {
if !closed && (i == 0 || i == n - 1) {
return Class::Endpoint;
}
let prev = verts[if i == 0 { n - 1 } else { i - 1 }];
let curr = verts[i];
let next = verts[if i + 1 == n { 0 } else { i + 1 }];
classify_angle(angle_between_deg(prev - curr, next - curr), max_angle_deg)
})
.collect()
}
fn compute_cuts(
verts: &[Point],
classes: &[Class],
closed: bool,
max_cut: f64,
) -> Vec<Option<Cut>> {
let n = verts.len();
let mut cuts = vec![None; n];
for i in 0..n {
if classes[i] != Class::Corner {
continue;
}
let (avail_back, back_stop) =
walk_available(verts, classes, i, closed, true);
let (avail_fwd, fwd_stop) = walk_available(verts, classes, i, closed, false);
let back_share = if back_stop == StopKind::Corner {
0.5
} else {
1.0
};
let fwd_share = if fwd_stop == StopKind::Corner {
0.5
} else {
1.0
};
let back_dist = (avail_back * back_share).min(max_cut).max(0.0);
let fwd_dist = (avail_fwd * fwd_share).min(max_cut).max(0.0);
let (p_back, t_back) = walk_distance(verts, i, back_dist, closed, true);
let (p_fwd, t_fwd) = walk_distance(verts, i, fwd_dist, closed, false);
const CUBIC_DEG_ELEV: f64 = 2.0 / 3.0;
let c1 = p_back + t_back * (back_dist * CUBIC_DEG_ELEV);
let c2 = p_fwd - t_fwd * (fwd_dist * CUBIC_DEG_ELEV);
let degenerate = back_dist <= DEGENERATE_EPS && fwd_dist <= DEGENERATE_EPS;
cuts[i] = Some(Cut {
p_back,
c1,
c2,
p_fwd,
degenerate,
});
}
cuts
}
fn walk_available(
verts: &[Point],
classes: &[Class],
start: usize,
closed: bool,
back: bool,
) -> (f64, StopKind) {
let n = verts.len();
let mut total = 0.0;
let mut cur = start;
loop {
let Some(nxt) = step(cur, n, closed, back) else {
return (total, StopKind::Endpoint);
};
total += (verts[nxt] - verts[cur]).hypot();
cur = nxt;
match classes[cur] {
Class::Collinear => continue,
Class::Corner => return (total, StopKind::Corner),
Class::NonCorner => return (total, StopKind::Other),
Class::Endpoint => return (total, StopKind::Endpoint),
}
}
}
fn walk_distance(
verts: &[Point],
start: usize,
distance: f64,
closed: bool,
back: bool,
) -> (Point, Vec2) {
let n = verts.len();
if distance <= 0.0 {
return (
verts[start],
tangent_at_vertex(verts, start, n, closed, back),
);
}
let mut remaining = distance;
let mut cur = start;
loop {
let Some(nxt) = step(cur, n, closed, back) else {
return (verts[cur], tangent_at_endpoint(verts, cur, n));
};
let seg = verts[nxt] - verts[cur];
let seg_len = seg.hypot();
if seg_len >= remaining {
let t = if seg_len > 0.0 {
remaining / seg_len
} else {
0.0
};
let p = verts[cur] + seg * t;
let forward = if back { -seg } else { seg };
let unit = unit_vec(forward);
return (p, unit);
}
remaining -= seg_len;
cur = nxt;
}
}
fn tangent_at_vertex(verts: &[Point], i: usize, n: usize, closed: bool, back: bool) -> Vec2 {
let forward_edge = if i + 1 < n {
Some(verts[i + 1] - verts[i])
} else if closed {
Some(verts[0] - verts[i])
} else {
None
};
let backward_edge = if i > 0 {
Some(verts[i] - verts[i - 1])
} else if closed {
Some(verts[i] - verts[n - 1])
} else {
None
};
let pick = if back {
backward_edge.or(forward_edge)
} else {
forward_edge.or(backward_edge)
};
pick.map(unit_vec).unwrap_or(Vec2::new(1.0, 0.0))
}
fn tangent_at_endpoint(verts: &[Point], i: usize, n: usize) -> Vec2 {
let edge = if i == 0 {
verts[1] - verts[0]
} else {
verts[i] - verts[i - 1]
};
let _ = n;
unit_vec(edge)
}
fn unit_vec(v: Vec2) -> Vec2 {
let len = v.hypot();
if len < DEGENERATE_EPS {
Vec2::new(1.0, 0.0)
} else {
v / len
}
}
fn step(cur: usize, n: usize, closed: bool, back: bool) -> Option<usize> {
if back {
if cur == 0 {
if closed {
Some(n - 1)
} else {
None
}
} else {
Some(cur - 1)
}
} else if cur + 1 == n {
if closed {
Some(0)
} else {
None
}
} else {
Some(cur + 1)
}
}
fn emit(verts: &[Point], classes: &[Class], cuts: &[Option<Cut>], closed: bool) -> Path {
let n = verts.len();
let mut path = Path::new();
if !closed {
path.move_to(verts[0]);
for i in 1..n {
emit_one(&mut path, verts, classes, cuts, i);
}
return path;
}
let start = pick_start(classes);
let start_class = classes[start];
match start_class {
Class::Corner => {
let c = cuts[start].expect("Corner without cut");
if c.degenerate {
path.move_to(verts[start]);
} else {
path.move_to(c.p_fwd);
}
}
_ => path.move_to(verts[start]),
}
for shift in 1..n {
let i = (start + shift) % n;
emit_one(&mut path, verts, classes, cuts, i);
}
if start_class == Class::Corner {
let c = cuts[start].expect("Corner without cut");
if c.degenerate {
path.line_to(verts[start]);
} else {
path.line_to(c.p_back);
path.curve_to(c.c1, c.c2, c.p_fwd);
}
}
path.close_path();
path
}
fn emit_one(path: &mut Path, verts: &[Point], classes: &[Class], cuts: &[Option<Cut>], i: usize) {
match classes[i] {
Class::NonCorner | Class::Endpoint => {
path.line_to(verts[i]);
}
Class::Collinear => {}
Class::Corner => {
let c = cuts[i].expect("Corner without cut");
if c.degenerate {
path.line_to(verts[i]);
} else {
path.line_to(c.p_back);
path.curve_to(c.c1, c.c2, c.p_fwd);
}
}
}
}
fn pick_start(classes: &[Class]) -> usize {
if let Some(i) = classes.iter().position(|c| *c == Class::NonCorner) {
return i;
}
if let Some(i) = classes.iter().position(|c| *c == Class::Corner) {
return i;
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::path::PathEl;
fn p(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn count_els(path: &Path) -> (usize, usize, usize, usize) {
let mut m = 0;
let mut l = 0;
let mut q = 0;
let mut c = 0;
for el in path.elements() {
match el {
PathEl::MoveTo(_) => m += 1,
PathEl::LineTo(_) => l += 1,
PathEl::QuadTo(_, _) => q += 1,
PathEl::ClosePath => c += 1,
_ => {}
}
}
(m, l, q, c)
}
fn default_opts() -> CornerRounding {
CornerRounding::default()
}
#[test]
fn empty_when_too_few_vertices() {
assert_eq!(
round_corners(&[], false, default_opts()).elements().len(),
0
);
assert_eq!(
round_corners(&[p(0.0, 0.0)], false, default_opts())
.elements()
.len(),
0,
);
assert_eq!(
round_corners(&[p(0.0, 0.0), p(1.0, 0.0)], true, default_opts())
.elements()
.len(),
0,
);
}
#[test]
fn unit_square_rounds_all_four_corners() {
let sq = [p(0.0, 0.0), p(1.0, 0.0), p(1.0, 1.0), p(0.0, 1.0)];
let path = round_corners(&sq, true, default_opts());
let (m, l, _, c) = count_els(&path);
let curves = count_curves(&path);
assert_eq!(m, 1, "one move_to");
assert_eq!(curves, 4, "four curve_to (one per corner)");
assert_eq!(c, 1, "one close_path");
assert_eq!(l, 4);
}
#[test]
fn classical_chaikin_case_controls_at_two_thirds_to_corner() {
let pts = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0)];
let path = round_corners(&pts, false, default_opts());
let mut found = false;
let v = p(10.0, 0.0);
let mut pen = Point::ORIGIN;
for el in path.elements() {
match el {
PathEl::MoveTo(pt) | PathEl::LineTo(pt) => pen = *pt,
PathEl::CurveTo(c1, c2, end) => {
let expected_c1 = pen + (v - pen) * (2.0 / 3.0);
let expected_c2 = *end + (v - *end) * (2.0 / 3.0);
assert!(
(*c1 - expected_c1).hypot() < 1e-9,
"C1 should be at 2/3 from P_back to V; got {c1:?} expected {expected_c1:?}",
);
assert!(
(*c2 - expected_c2).hypot() < 1e-9,
"C2 should be at 2/3 from P_fwd to V; got {c2:?} expected {expected_c2:?}",
);
found = true;
pen = *end;
}
_ => {}
}
}
assert!(found);
}
#[test]
fn near_straight_polyline_not_rounded() {
let pts = [p(0.0, 0.0), p(5.0, 0.01), p(10.0, 0.0)];
let opts = CornerRounding {
max_angle_deg: 170.0,
..Default::default()
};
let path = round_corners(&pts, false, opts);
let (m, l, _, _) = count_els(&path);
assert_eq!(m, 1);
assert_eq!(
count_curves(&path),
0,
"no rounding when angle exceeds max_angle_deg"
);
assert!(l >= 2, "endpoints emitted as line_to");
}
#[test]
fn max_cut_caps_cut_distance() {
let pts = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0)];
let opts = CornerRounding {
max_cut: 0.5,
..Default::default()
};
let path = round_corners(&pts, false, opts);
let mut found = false;
let corner = p(10.0, 0.0);
let mut pen = Point::ORIGIN;
for el in path.elements() {
match el {
PathEl::MoveTo(pt) | PathEl::LineTo(pt) => pen = *pt,
PathEl::CurveTo(_, _, end) => {
assert!((pen - corner).hypot() <= 0.5 + 1e-9);
assert!((*end - corner).hypot() <= 0.5 + 1e-9);
pen = *end;
found = true;
}
_ => {}
}
}
assert!(found, "should have produced a cubic");
}
#[test]
fn collinear_walk_stays_in_back_edge_when_max_cut_short() {
let a = p(0.0, 0.0);
let b = p(2.0, 0.0);
let c = p(6.0, 0.0);
let d = p(6.0, 10.0);
let opts = CornerRounding {
max_cut: 1.0,
..Default::default()
};
let path = round_corners(&[a, b, c, d], false, opts);
let qs = first_curve_start(&path);
assert!(
(qs.x - 5.0).abs() < 1e-9 && qs.y.abs() < 1e-9,
"P_back should be at (5, 0); got {qs:?}",
);
}
#[test]
fn collinear_walk_past_subdivision_vertex() {
let a = p(0.0, 0.0);
let b = p(10.0, 0.0);
let c = p(11.0, 0.0);
let d = p(11.0, 10.0);
let opts = CornerRounding {
max_cut: 5.0,
..Default::default()
};
let path = round_corners(&[a, b, c, d], false, opts);
let qs = first_curve_start(&path);
assert!(
(qs.x - 6.0).abs() < 1e-9 && qs.y.abs() < 1e-9,
"P_back should be at (6, 0); got {qs:?}",
);
let line_to_count = path
.elements()
.iter()
.filter(|el| matches!(el, PathEl::LineTo(_)))
.count();
assert_eq!(line_to_count, 2, "B should be absorbed");
}
#[test]
fn endpoint_walk_uses_full_available_distance() {
let a = p(0.0, 0.0);
let b = p(2.0, 0.0);
let c = p(6.0, 0.0);
let d = p(6.0, 10.0);
let path = round_corners(&[a, b, c, d], false, default_opts());
let qs = first_curve_start(&path);
assert!((qs - a).hypot() < 1e-9, "P_back should be at A; got {qs:?}",);
}
fn first_curve_start(path: &Path) -> Point {
let mut last_pen = Point::ORIGIN;
for el in path.elements() {
match el {
PathEl::MoveTo(pt) | PathEl::LineTo(pt) => last_pen = *pt,
PathEl::CurveTo(_, _, _) => return last_pen,
_ => {}
}
}
panic!("no curve_to in path");
}
#[test]
fn adjacent_corners_share_edge_halfway() {
let r = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0), p(0.0, 10.0)];
let path = round_corners(&r, true, default_opts());
let mut curves = vec![];
let mut last_pen: Option<Point> = None;
for el in path.elements() {
match el {
PathEl::MoveTo(pt) | PathEl::LineTo(pt) => last_pen = Some(*pt),
PathEl::CurveTo(c1, c2, end) => {
curves.push((last_pen.unwrap(), *c1, *c2, *end));
last_pen = Some(*end);
}
_ => {}
}
}
assert_eq!(curves.len(), 4);
let expected = 5.0 * 2.0 / 3.0;
for &(start, c1, c2, end) in &curves {
assert!(((start - c1).hypot() - expected).abs() < 1e-9);
assert!(((end - c2).hypot() - expected).abs() < 1e-9);
}
}
#[test]
fn open_polyline_keeps_endpoints() {
let pts = [p(0.0, 0.0), p(5.0, 0.0), p(5.0, 5.0)];
let path = round_corners(&pts, false, default_opts());
if let Some(PathEl::MoveTo(first)) = path.elements().first() {
assert_eq!(*first, pts[0]);
} else {
panic!("expected move_to first");
}
let mut last_point: Option<Point> = None;
for el in path.elements() {
match el {
PathEl::LineTo(pt) => last_point = Some(*pt),
PathEl::CurveTo(_, _, end) => last_point = Some(*end),
_ => {}
}
}
assert_eq!(last_point.unwrap(), pts[2]);
}
#[test]
fn endpoint_adjacent_corner_uses_full_edge() {
let pts = [p(0.0, 0.0), p(4.0, 0.0), p(4.0, 4.0)];
let path = round_corners(&pts, false, default_opts());
let mut found = None;
let mut last_pen = pts[0];
for el in path.elements() {
match el {
PathEl::MoveTo(pt) | PathEl::LineTo(pt) => last_pen = *pt,
PathEl::CurveTo(c1, c2, end) => {
found = Some((last_pen, *c1, *c2, *end));
}
_ => {}
}
}
let (start, c1, c2, end) = found.expect("should have a curve");
assert!((start - pts[0]).hypot() < 1e-9);
let expected_c1 = start + (pts[1] - start) * (2.0 / 3.0);
let expected_c2 = end + (pts[1] - end) * (2.0 / 3.0);
assert!((c1 - expected_c1).hypot() < 1e-9);
assert!((c2 - expected_c2).hypot() < 1e-9);
assert!((end - pts[2]).hypot() < 1e-9);
}
#[test]
fn collinear_walk_through_non_straight_run_keeps_tangent_local() {
let a = p(0.0, 0.0);
let b = p(10.0, 1e-5); let c = p(20.0, 0.0);
let d = p(20.0, 10.0);
let opts = CornerRounding {
max_cut: 15.0, ..Default::default()
};
let path = round_corners(&[a, b, c, d], false, opts);
let mut last_pen = Point::ORIGIN;
let mut c1_observed: Option<Point> = None;
let mut p_back_observed: Option<Point> = None;
for el in path.elements() {
match el {
PathEl::MoveTo(pt) | PathEl::LineTo(pt) => last_pen = *pt,
PathEl::CurveTo(c1, _, _) => {
p_back_observed = Some(last_pen);
c1_observed = Some(*c1);
break;
}
_ => {}
}
}
let pb = p_back_observed.expect("p_back");
let c1 = c1_observed.expect("c1");
assert!(pb.x >= 0.0 && pb.x <= 10.0 + 1e-9, "P_back on A-B");
let dir_to_c = (c - pb).normalize();
let dir_along_ab = (b - a).normalize();
let c1_dir = (c1 - pb).normalize();
let dot_local = c1_dir.x * dir_along_ab.x + c1_dir.y * dir_along_ab.y;
let dot_to_c = c1_dir.x * dir_to_c.x + c1_dir.y * dir_to_c.y;
assert!(
dot_local > 0.9999999,
"C1 - P_back should align with A-B tangent (dot = {dot_local}), not the line to C (dot = {dot_to_c})"
);
}
fn count_curves(path: &Path) -> usize {
path.elements()
.iter()
.filter(|el| matches!(el, PathEl::CurveTo(_, _, _)))
.count()
}
}