Skip to main content

pax_runtime_api/drawing/
path_smoothing.rs

1use kurbo::{BezPath, PathEl, Point};
2
3use super::PathSmoothing;
4
5const EPSILON: f64 = 1e-6;
6
7#[derive(Clone, Copy)]
8struct SmoothingParams {
9    angle_cut_degrees: f64,
10    passes: usize,
11}
12
13/// Converts long polyline runs in `path` to cubic Bezier segments.
14///
15/// Existing quadratic and cubic segments are preserved. Sharp corners split a
16/// line run so smoothing does not round deliberate angular forms.
17pub fn smooth_bez_path(path: &BezPath, smoothing: PathSmoothing) -> BezPath {
18    let Some(params) = smoothing_params(smoothing) else {
19        return path.clone();
20    };
21
22    let mut output = BezPath::new();
23    let mut run = Vec::new();
24    let mut contour_start = None;
25
26    for element in path.elements() {
27        match *element {
28            PathEl::MoveTo(point) => {
29                flush_run(&mut output, &mut run, params);
30                output.move_to(point);
31                run.clear();
32                run.push(point);
33                contour_start = Some(point);
34            }
35            PathEl::LineTo(point) => {
36                if run.is_empty() {
37                    output.line_to(point);
38                } else {
39                    push_distinct(&mut run, point);
40                }
41            }
42            PathEl::QuadTo(control, point) => {
43                flush_run(&mut output, &mut run, params);
44                output.quad_to(control, point);
45                run.clear();
46                run.push(point);
47            }
48            PathEl::CurveTo(control_1, control_2, point) => {
49                flush_run(&mut output, &mut run, params);
50                output.curve_to(control_1, control_2, point);
51                run.clear();
52                run.push(point);
53            }
54            PathEl::ClosePath => {
55                if let (Some(start), Some(last)) = (contour_start, run.last().copied()) {
56                    if distance(last, start) > EPSILON {
57                        run.push(start);
58                    }
59                }
60                flush_run(&mut output, &mut run, params);
61                output.close_path();
62                run.clear();
63                contour_start = None;
64            }
65        }
66    }
67
68    flush_run(&mut output, &mut run, params);
69    output
70}
71
72fn smoothing_params(smoothing: PathSmoothing) -> Option<SmoothingParams> {
73    match smoothing {
74        PathSmoothing::None => None,
75        PathSmoothing::Light => Some(SmoothingParams {
76            angle_cut_degrees: 130.0,
77            passes: 1,
78        }),
79        PathSmoothing::Strong => Some(SmoothingParams {
80            angle_cut_degrees: 95.0,
81            passes: 2,
82        }),
83    }
84}
85
86fn flush_run(output: &mut BezPath, run: &mut Vec<Point>, params: SmoothingParams) {
87    let clean = clean_points(run);
88    if clean.len() < 2 {
89        run.clear();
90        return;
91    }
92
93    let mut cuts = vec![0usize];
94    for index in 1..clean.len() - 1 {
95        if angle_degrees(clean[index - 1], clean[index], clean[index + 1])
96            <= params.angle_cut_degrees
97        {
98            cuts.push(index);
99        }
100    }
101    cuts.push(clean.len() - 1);
102
103    for pair in cuts.windows(2) {
104        let start = pair[0];
105        let end = pair[1];
106        if end <= start {
107            continue;
108        }
109        let subrun = smooth_points(&clean[start..=end], params.passes);
110        emit_subrun(output, &subrun);
111    }
112
113    run.clear();
114}
115
116fn emit_subrun(output: &mut BezPath, points: &[Point]) {
117    if points.len() < 2 {
118        return;
119    }
120    if points.len() == 2 {
121        output.line_to(points[1]);
122        return;
123    }
124
125    for index in 0..points.len() - 1 {
126        let p0 = if index == 0 {
127            points[index]
128        } else {
129            points[index - 1]
130        };
131        let p1 = points[index];
132        let p2 = points[index + 1];
133        let p3 = if index + 2 < points.len() {
134            points[index + 2]
135        } else {
136            points[index + 1]
137        };
138
139        let control_1 = p1 + (p2 - p0) * (1.0 / 6.0);
140        let control_2 = p2 - (p3 - p1) * (1.0 / 6.0);
141        output.curve_to(control_1, control_2, p2);
142    }
143}
144
145fn smooth_points(points: &[Point], passes: usize) -> Vec<Point> {
146    if points.len() < 4 || passes == 0 {
147        return points.to_vec();
148    }
149
150    let mut output = points.to_vec();
151    for _ in 0..passes {
152        let mut next = Vec::with_capacity(output.len());
153        next.push(output[0]);
154        for index in 1..output.len() - 1 {
155            let point = Point::new(
156                output[index - 1].x * 0.25 + output[index].x * 0.5 + output[index + 1].x * 0.25,
157                output[index - 1].y * 0.25 + output[index].y * 0.5 + output[index + 1].y * 0.25,
158            );
159            next.push(point);
160        }
161        next.push(output[output.len() - 1]);
162        output = next;
163    }
164    output
165}
166
167fn clean_points(points: &[Point]) -> Vec<Point> {
168    let mut clean = Vec::with_capacity(points.len());
169    for point in points {
170        push_distinct(&mut clean, *point);
171    }
172    clean
173}
174
175fn push_distinct(points: &mut Vec<Point>, point: Point) {
176    if points
177        .last()
178        .map(|last| distance(*last, point) > EPSILON)
179        .unwrap_or(true)
180    {
181        points.push(point);
182    }
183}
184
185fn angle_degrees(previous: Point, current: Point, next: Point) -> f64 {
186    let vector_1 = previous - current;
187    let vector_2 = next - current;
188    let length_1 = vector_1.hypot();
189    let length_2 = vector_2.hypot();
190    if length_1 < EPSILON || length_2 < EPSILON {
191        return 180.0;
192    }
193    let dot = ((vector_1.x * vector_2.x) + (vector_1.y * vector_2.y)) / (length_1 * length_2);
194    dot.clamp(-1.0, 1.0).acos().to_degrees()
195}
196
197fn distance(a: Point, b: Point) -> f64 {
198    (a - b).hypot()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn none_preserves_path() {
207        let mut path = BezPath::new();
208        path.move_to((0.0, 0.0));
209        path.line_to((50.0, 20.0));
210        path.line_to((100.0, 0.0));
211
212        assert_eq!(smooth_bez_path(&path, PathSmoothing::None), path);
213    }
214
215    #[test]
216    fn smoothing_converts_gentle_line_run_to_curves() {
217        let mut path = BezPath::new();
218        path.move_to((0.0, 0.0));
219        path.line_to((25.0, 10.0));
220        path.line_to((50.0, 12.0));
221        path.line_to((75.0, 10.0));
222        path.line_to((100.0, 0.0));
223
224        let smoothed = smooth_bez_path(&path, PathSmoothing::Light);
225
226        assert!(smoothed
227            .elements()
228            .iter()
229            .any(|element| matches!(element, PathEl::CurveTo(..))));
230    }
231
232    #[test]
233    fn smoothing_preserves_sharp_corners_as_lines() {
234        let mut path = BezPath::new();
235        path.move_to((0.0, 0.0));
236        path.line_to((50.0, 0.0));
237        path.line_to((50.0, 50.0));
238
239        let smoothed = smooth_bez_path(&path, PathSmoothing::Light);
240
241        assert!(smoothed
242            .elements()
243            .iter()
244            .any(|element| matches!(element, PathEl::LineTo(..))));
245    }
246}