extrude_polyline/
lib.rs

1//! Extrude Polyline
2//! This project is a port of Matt Deslauriers's [extrude-polyline](https://github.com/mattdesl/extrude-polyline) library.
3
4#[derive(Debug, Clone, Copy, PartialEq, Hash)]
5pub enum StrokeJoin {
6    Miter,
7    Bevel,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Hash)]
11pub enum StrokeCap {
12    Butt,
13    Square,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct Stroke {
18    pub miter_limit: f64,
19    pub thickness: f64,
20    pub join: StrokeJoin,
21    pub cap: StrokeCap,
22}
23
24impl Default for Stroke {
25    fn default() -> Self {
26        Self {
27            miter_limit: 10.0,
28            thickness: 1.0,
29            join: StrokeJoin::Miter,
30            cap: StrokeCap::Butt,
31        }
32    }
33}
34
35#[derive(Debug, Default, Clone, PartialEq)]
36pub struct Mesh {
37    pub positions: Vec<[f64; 2]>,
38    pub indices: Vec<[u32; 3]>,
39}
40
41#[derive(Debug, Copy, Clone, PartialEq)]
42struct StrokeState {
43    last_flip: f64,
44    started: bool,
45    normal: Option<[f64; 2]>,
46}
47
48impl Default for StrokeState {
49    fn default() -> Self {
50        Self {
51            last_flip: -1.0,
52            started: false,
53            normal: None,
54        }
55    }
56}
57
58#[derive(Debug, Copy, Clone, PartialEq)]
59struct SegArgs {
60    index: u32,
61    last: [f64; 2],
62    cur: [f64; 2],
63    next: Option<[f64; 2]>,
64    half_thick: f64,
65}
66
67impl Stroke {
68    pub fn thickness(mut self, thickness: f64) -> Self {
69        self.thickness = thickness;
70        self
71    }
72
73    pub fn miter_limit(mut self, miter_limit: f64) -> Self {
74        self.miter_limit = miter_limit;
75        self
76    }
77
78    pub fn join(mut self, join: StrokeJoin) -> Self {
79        self.join = join;
80        self
81    }
82
83    pub fn cap(mut self, cap: StrokeCap) -> Self {
84        self.cap = cap;
85        self
86    }
87
88    pub fn build(self, points: &[[f64; 2]]) -> Mesh {
89        self.build_with_thickness_fn(points, |_, _, _| self.thickness)
90    }
91
92    pub fn build_with_thickness_fn(
93        self,
94        points: &[[f64; 2]],
95        mut thickness_fn: impl FnMut([f64; 2], usize, &[[f64; 2]]) -> f64,
96    ) -> Mesh {
97        let mut complex = Mesh::default();
98
99        if points.len() <= 1 {
100            return complex;
101        }
102
103        let mut state = StrokeState::default();
104        // join each segment
105        let mut count = 0;
106        for (i, pt) in points.windows(2).enumerate() {
107            if let [last, current] = pt {
108                let next = points.get(i + 2).copied();
109                let thickness = thickness_fn(*current, i, points);
110                let amt = self.seg(
111                    &mut state,
112                    &mut complex,
113                    SegArgs {
114                        index: count,
115                        last: *last,
116                        cur: *current,
117                        next,
118                        half_thick: thickness * 0.5,
119                    },
120                );
121                count += amt;
122            }
123        }
124
125        complex
126    }
127
128    fn seg(
129        &self,
130        state: &mut StrokeState,
131        complex: &mut Mesh,
132        SegArgs {
133            index,
134            mut last,
135            mut cur,
136            next,
137            half_thick,
138        }: SegArgs,
139    ) -> u32 {
140        let mut count = 0;
141        let cells = &mut complex.indices;
142        let positions = &mut complex.positions;
143        let cap_square = matches!(self.cap, StrokeCap::Square);
144        let join_bevel = matches!(self.join, StrokeJoin::Bevel);
145
146        // get unit direction of line
147        let line_a = direction(cur, last);
148
149        // if we don't yet have a normal from previous join,
150        // compute based on line start - end
151        // note that normal is now always defined so we can unwrap it
152        if state.normal.is_none() {
153            state.normal = Some(normal(line_a));
154        }
155
156        // if we haven't started yet, add the first two points
157        if !state.started {
158            state.started = true;
159
160            // if the end cap is type square, we can just push the verts out a bit
161            if cap_square {
162                last = scale_and_add(last, line_a, -half_thick);
163            }
164            positions.extend_from_slice(&extrusions(last, state.normal.unwrap(), half_thick));
165        }
166
167        cells.push([index, index + 1, index + 2]);
168
169        /*
170        // now determine the type of join with next segment
171
172        - round (TODO)
173        - bevel
174        - miter
175        - none (i.e. no next segment, use normal)
176         */
177        if let Some(next) = next {
178            // we have a next segment, start with miter
179            // get unit dir of next line
180            let line_b = direction(next, cur);
181
182            // stores tangent & miter
183            let (tangent, miter, miter_len) = compute_miter(line_a, line_b, half_thick);
184
185            // get orientation
186            let mut flip = if dot(tangent, state.normal.unwrap()) < 0.0 {
187                -1.0
188            } else {
189                1.0
190            };
191
192            let mut bevel = join_bevel;
193            if matches!(self.join, StrokeJoin::Miter) {
194                let limit = miter_len / half_thick;
195                if limit > self.miter_limit {
196                    bevel = true;
197                }
198            }
199
200            if bevel {
201                // next two points in our first segment
202                let tmp = scale_and_add(cur, state.normal.unwrap(), -half_thick * flip);
203                positions.push(tmp);
204                let tmp = scale_and_add(cur, miter, miter_len * flip);
205                positions.push(tmp);
206
207                cells.push(if state.last_flip != -flip {
208                    [index, index + 2, index + 3]
209                } else {
210                    [index + 2, index + 1, index + 3]
211                });
212                cells.push([index + 2, index + 3, index + 4]);
213
214                let tmp = normal(line_b);
215                state.normal = Some(tmp); // store normal for next round
216
217                let tmp = scale_and_add(cur, tmp, -half_thick * flip);
218                positions.push(tmp);
219
220                // the miter is now the normal for our next join
221                count += 3;
222            } else {
223                // miter
224                // next two points for our miter join
225                positions.extend_from_slice(&extrusions(cur, miter, miter_len));
226
227                cells.push(if state.last_flip == 1.0 {
228                    [index, index + 2, index + 3]
229                } else {
230                    [index + 2, index + 1, index + 3]
231                });
232
233                flip = -1.0;
234
235                // the miter is now the normal for our next join
236                state.normal = Some(miter);
237                count += 2
238            }
239            state.last_flip = flip;
240        } else {
241            // no next segment, simple extrusion
242            // now reset normal to finish cap
243            state.normal = Some(normal(line_a));
244
245            // push square end cap out a bit
246            if cap_square {
247                cur = scale_and_add(cur, line_a, half_thick);
248            }
249
250            positions.extend_from_slice(&extrusions(cur, state.normal.unwrap(), half_thick));
251            cells.push(if state.last_flip == 1.0 {
252                [index, index + 2, index + 3]
253            } else {
254                [index + 2, index + 1, index + 3]
255            });
256
257            count += 2;
258        }
259        count
260    }
261}
262
263fn extrusions(point: [f64; 2], normal: [f64; 2], scale: f64) -> [[f64; 2]; 2] {
264    [
265        scale_and_add(point, normal, -scale),
266        scale_and_add(point, normal, scale),
267    ]
268}
269
270fn scale_and_add(a: [f64; 2], b: [f64; 2], scale: f64) -> [f64; 2] {
271    [a[0] + b[0] * scale, a[1] + b[1] * scale]
272}
273
274fn subtract(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
275    [a[0] - b[0], a[1] - b[1]]
276}
277
278fn add(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
279    [a[0] + b[0], a[1] + b[1]]
280}
281
282fn normal(dir: [f64; 2]) -> [f64; 2] {
283    [-dir[1], dir[0]]
284}
285
286fn direction(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
287    normalize(subtract(a, b))
288}
289
290fn compute_miter(line_a: [f64; 2], line_b: [f64; 2], half_thick: f64) -> ([f64; 2], [f64; 2], f64) {
291    // get tangent line
292    let tangent = normalize(add(line_a, line_b));
293
294    // get miter as a unit vector
295    let miter = [-tangent[1], tangent[0]];
296    let tmp = [-line_a[1], line_a[0]];
297
298    // get the necessary length of our miter
299    (tangent, miter, half_thick / dot(miter, tmp))
300}
301
302fn normalize(a: [f64; 2]) -> [f64; 2] {
303    let [x, y] = a;
304    let len = x * x + y * y;
305    let len = if len > 0.0 { 1.0 / len.sqrt() } else { len };
306    [a[0] * len, a[1] * len]
307}
308
309fn dot(a: [f64; 2], b: [f64; 2]) -> f64 {
310    a[0] * b[0] + a[1] * b[1]
311}
312
313#[cfg(test)]
314mod tests {
315    use super::{Mesh, Stroke};
316
317    #[test]
318    fn should_extrude_path_two_points() {
319        let mesh = Stroke::default()
320            .thickness(2.5)
321            .build(&[[2.0, 0.0], [2.0, 10.0]]);
322
323        assert_eq!(
324            mesh,
325            Mesh {
326                positions: vec![[3.25, 0.0], [0.75, 0.0], [3.25, 10.0], [0.75, 10.0]],
327                indices: vec![[0, 1, 2], [2, 1, 3]]
328            }
329        )
330    }
331
332    #[test]
333    fn should_extrude_path_three_points() {
334        let mesh = Stroke::default()
335            .thickness(1.0)
336            .build(&[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]]);
337
338        assert_eq!(
339            mesh,
340            Mesh {
341                positions: vec![
342                    [0.35355339059327373, -0.35355339059327373],
343                    [-0.35355339059327373, 0.35355339059327373],
344                    [1.0, 0.2928932188134524],
345                    [1.0, 1.7071067811865475],
346                    [1.6464466094067263, -0.35355339059327373],
347                    [2.353553390593274, 0.35355339059327373]
348                ],
349                indices: vec![[0, 1, 2], [2, 1, 3], [2, 3, 4], [4, 3, 5]]
350            }
351        )
352    }
353}