Skip to main content

dinamika_cpu/path/
stroke.rs

1//! Stroking contours.
2//!
3//! A stroke is built from "stamps": each segment turns into a rectangle, joins
4//! and caps into triangles/sectors/discs. All convex stamps are oriented the
5//! same way (counter-clockwise) and filled by the non-zero winding rule, so
6//! their union produces a correct stroke without seams.
7//!
8//! # Known limitation: AA seams at the junctions of stamps
9//!
10//! The union by the non-zero winding rule is correct *inside* the shape:
11//! overlapping stamps do not double the coverage. But on anti-aliased
12//! boundaries, where a segment meets a join or a cap, conflation artifacts are
13//! possible — at the junction the edge coverage of the two stamps does not add
14//! up perfectly, and a seam may be barely noticeable. For an MVP this is
15//! acceptable; the seams can be removed entirely only by building a single
16//! stroke outline instead of a set of stamps.
17
18use crate::geometry::Point;
19use crate::path::Contour;
20use std::f32::consts::PI;
21
22/// The shape of the ends of open lines.
23#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
24pub enum LineCap {
25    /// A cut exactly at the end.
26    #[default]
27    Butt,
28    /// A semicircle.
29    Round,
30    /// A square extension by half the width.
31    Square,
32}
33
34/// The shape of a join between segments.
35#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
36pub enum LineJoin {
37    /// A sharp corner (with a `miter_limit` constraint).
38    #[default]
39    Miter,
40    /// A rounded join.
41    Round,
42    /// A beveled corner.
43    Bevel,
44}
45
46/// Stroke parameters.
47///
48/// A width of `0.0` (or less) means a "hairline": the line is drawn exactly one
49/// device pixel wide regardless of scale (see [`Pixmap::stroke_path`]).
50///
51/// A dash pattern is given by a non-empty `dash`: an alternation of lengths
52/// "dash, gap, dash, …" in user units. An odd-length list is implicitly
53/// doubled. `dash_offset` shifts the phase of the pattern.
54///
55/// [`Pixmap::stroke_path`]: crate::Pixmap::stroke_path
56#[derive(Clone, Debug)]
57pub struct Stroke {
58    pub width: f32,
59    pub line_cap: LineCap,
60    pub line_join: LineJoin,
61    pub miter_limit: f32,
62    /// The dash pattern; empty — a solid line.
63    pub dash: Vec<f32>,
64    /// The dash phase offset.
65    pub dash_offset: f32,
66}
67
68impl Default for Stroke {
69    fn default() -> Self {
70        Stroke {
71            width: 1.0,
72            line_cap: LineCap::Butt,
73            line_join: LineJoin::Miter,
74            miter_limit: 4.0,
75            dash: Vec::new(),
76            dash_offset: 0.0,
77        }
78    }
79}
80
81impl Stroke {
82    /// A solid stroke of the given width with default settings.
83    pub fn new(width: f32) -> Self {
84        Stroke { width, ..Stroke::default() }
85    }
86
87    /// Whether the stroke is a "hairline" (width `<= 0`).
88    #[inline]
89    pub fn is_hairline(&self) -> bool {
90        self.width <= 0.0
91    }
92}
93
94/// Builds a set of convex polygons (in screen coordinates) whose union is the
95/// stroke of the contours `contours`.
96///
97/// The `stroke` parameters are already converted to screen units (see
98/// `scaled_stroke` in the `pixmap` module): width and dash intervals are in
99/// device pixels.
100pub(crate) fn build_stroke(contours: &[Contour], stroke: &Stroke, tolerance: f32) -> Vec<Vec<Point>> {
101    let r = (stroke.width * 0.5).max(0.0);
102    let mut polys: Vec<Vec<Point>> = Vec::new();
103    if r <= 0.0 {
104        return polys;
105    }
106
107    // A dash splits the contours into separate "runs" — open polylines.
108    if !stroke.dash.is_empty() {
109        let dashed = apply_dash(contours, &stroke.dash, stroke.dash_offset);
110        for c in &dashed {
111            stroke_contour(c, r, stroke, tolerance, &mut polys);
112        }
113        return polys;
114    }
115
116    for c in contours {
117        stroke_contour(c, r, stroke, tolerance, &mut polys);
118    }
119    polys
120}
121
122/// Splits contours into "runs" by the dash pattern `intervals` (alternation
123/// "dash, gap, …"), starting from the phase offset `offset`.
124///
125/// The pattern is cyclic; an odd-length list is implicitly doubled. Closed
126/// contours are turned into a set of open runs. The intervals and offset are
127/// given in the same units as the contour points (screen pixels).
128fn apply_dash(contours: &[Contour], intervals: &[f32], offset: f32) -> Vec<Contour> {
129    // The pattern must have an even length: "dash, gap". An odd one is doubled.
130    let mut pattern: Vec<f32> = intervals.iter().map(|&v| v.max(0.0)).collect();
131    if pattern.len() % 2 == 1 {
132        let dup = pattern.clone();
133        pattern.extend(dup);
134    }
135    let total: f32 = pattern.iter().sum();
136    if total <= 0.0 {
137        return contours.to_vec();
138    }
139
140    let mut out: Vec<Contour> = Vec::new();
141    for contour in contours {
142        // A closed contour is traversed as a polyline with a return to the start.
143        let mut pts: Vec<Point> = contour.points.clone();
144        if contour.closed && pts.len() >= 2 {
145            pts.push(pts[0]);
146        }
147        if pts.len() < 2 {
148            continue;
149        }
150
151        // Initial phase: the interval index and the remainder until its end.
152        let mut phase = offset.rem_euclid(total);
153        let mut idx = 0usize;
154        while phase >= pattern[idx] {
155            phase -= pattern[idx];
156            idx = (idx + 1) % pattern.len();
157        }
158        let mut remaining = pattern[idx] - phase;
159        let mut on = idx.is_multiple_of(2);
160
161        let mut current: Vec<Point> = Vec::new();
162        if on {
163            current.push(pts[0]);
164        }
165
166        for w in pts.windows(2) {
167            let (mut a, b) = (w[0], w[1]);
168            let mut seg_len = a.distance(b);
169            if seg_len <= 1e-9 {
170                continue;
171            }
172            let dir = (b - a).normalize();
173            // Move along the segment, cutting at the dash interval boundaries.
174            while seg_len > remaining {
175                let cut = a + dir * remaining;
176                if on {
177                    current.push(cut);
178                    flush_dash(&mut current, &mut out);
179                } else {
180                    current.clear();
181                    current.push(cut);
182                }
183                a = cut;
184                seg_len -= remaining;
185                idx = (idx + 1) % pattern.len();
186                remaining = pattern[idx];
187                on = !on;
188            }
189            remaining -= seg_len;
190            if on {
191                current.push(b);
192            }
193        }
194        flush_dash(&mut current, &mut out);
195    }
196    out
197}
198
199/// Finishes the accumulated run as an open contour (if it has ≥2 points).
200fn flush_dash(current: &mut Vec<Point>, out: &mut Vec<Contour>) {
201    if current.len() >= 2 {
202        out.push(Contour { points: std::mem::take(current), closed: false });
203    } else {
204        current.clear();
205    }
206}
207
208fn stroke_contour(
209    contour: &Contour,
210    r: f32,
211    stroke: &Stroke,
212    tolerance: f32,
213    polys: &mut Vec<Vec<Point>>,
214) {
215    let pts = dedupe(&contour.points, contour.closed);
216
217    if pts.len() < 2 {
218        // Degenerate contour: a point is drawn only for a round cap.
219        if pts.len() == 1 && stroke.line_cap == LineCap::Round {
220            push_disc(pts[0], r, tolerance, polys);
221        }
222        return;
223    }
224
225    let n = pts.len();
226    let closed = contour.closed;
227    let seg_count = if closed { n } else { n - 1 };
228
229    // Rectangles along the segments.
230    for i in 0..seg_count {
231        let a = pts[i];
232        let b = pts[(i + 1) % n];
233        let dir = (b - a).normalize();
234        if dir == Point::ZERO {
235            continue;
236        }
237        let normal = dir.left_normal() * r;
238        push_ccw(vec![a + normal, b + normal, b - normal, a - normal], polys);
239    }
240
241    // Joins.
242    if closed {
243        for i in 0..n {
244            let prev = pts[(i + n - 1) % n];
245            let v = pts[i];
246            let next = pts[(i + 1) % n];
247            add_join(prev, v, next, r, stroke, tolerance, polys);
248        }
249    } else {
250        for i in 1..n - 1 {
251            add_join(pts[i - 1], pts[i], pts[i + 1], r, stroke, tolerance, polys);
252        }
253        // Caps.
254        let start_dir = (pts[0] - pts[1]).normalize();
255        let end_dir = (pts[n - 1] - pts[n - 2]).normalize();
256        add_cap(pts[0], start_dir, r, stroke.line_cap, tolerance, polys);
257        add_cap(pts[n - 1], end_dir, r, stroke.line_cap, tolerance, polys);
258    }
259}
260
261fn add_join(
262    prev: Point,
263    v: Point,
264    next: Point,
265    r: f32,
266    stroke: &Stroke,
267    tolerance: f32,
268    polys: &mut Vec<Vec<Point>>,
269) {
270    let din = (v - prev).normalize();
271    let dout = (next - v).normalize();
272    if din == Point::ZERO || dout == Point::ZERO {
273        return;
274    }
275
276    match stroke.line_join {
277        LineJoin::Round => {
278            push_disc(v, r, tolerance, polys);
279        }
280        LineJoin::Bevel => {
281            add_bevel(v, din, dout, r, polys);
282        }
283        LineJoin::Miter => {
284            add_bevel(v, din, dout, r, polys);
285            add_miter(v, din, dout, r, stroke.miter_limit, polys);
286        }
287    }
288}
289
290/// Fills the "wedge" between the ends of adjacent segments on both sides.
291fn add_bevel(v: Point, din: Point, dout: Point, r: f32, polys: &mut Vec<Vec<Point>>) {
292    let nin = din.left_normal() * r;
293    let nout = dout.left_normal() * r;
294    push_ccw(vec![v, v + nin, v + nout], polys);
295    push_ccw(vec![v, v - nin, v - nout], polys);
296}
297
298/// Adds the miter tip if it is within `miter_limit`.
299fn add_miter(
300    v: Point,
301    din: Point,
302    dout: Point,
303    r: f32,
304    miter_limit: f32,
305    polys: &mut Vec<Vec<Point>>,
306) {
307    let nin = din.left_normal() * r;
308    let nout = dout.left_normal() * r;
309    // The outer side is determined by the sign of the turn.
310    let turn = din.cross(dout);
311    let (a, da, b, db, base_a, base_b) = if turn < 0.0 {
312        // turning right — the outer side is "+"
313        (v + nin, din, v + nout, dout, v + nin, v + nout)
314    } else {
315        (v - nin, din, v - nout, dout, v - nin, v - nout)
316    };
317    if let Some(m) = line_intersection(a, da, b, db) {
318        if m.distance(v) <= miter_limit * r {
319            push_ccw(vec![base_a, m, base_b], polys);
320        }
321    }
322}
323
324fn add_cap(
325    p: Point,
326    out_dir: Point,
327    r: f32,
328    cap: LineCap,
329    tolerance: f32,
330    polys: &mut Vec<Vec<Point>>,
331) {
332    if out_dir == Point::ZERO {
333        return;
334    }
335    match cap {
336        LineCap::Butt => {}
337        LineCap::Round => push_disc(p, r, tolerance, polys),
338        LineCap::Square => {
339            let n = out_dir.left_normal() * r;
340            let e = out_dir * r;
341            push_ccw(vec![p + n, p - n, p - n + e, p + n + e], polys);
342        }
343    }
344}
345
346/// Intersection of the two lines `p + t·d` and `q + s·e`.
347fn line_intersection(p: Point, d: Point, q: Point, e: Point) -> Option<Point> {
348    let denom = d.cross(e);
349    if denom.abs() < 1e-6 {
350        return None;
351    }
352    let t = (q - p).cross(e) / denom;
353    Some(p + d * t)
354}
355
356/// Adds a disc as a convex polygon.
357fn push_disc(center: Point, r: f32, tolerance: f32, polys: &mut Vec<Vec<Point>>) {
358    let segs = arc_segments(r, tolerance);
359    let mut pts = Vec::with_capacity(segs);
360    for i in 0..segs {
361        let a = (i as f32 / segs as f32) * 2.0 * PI;
362        pts.push(Point::new(center.x + r * a.cos(), center.y + r * a.sin()));
363    }
364    push_ccw(pts, polys);
365}
366
367/// The number of segments to approximate an arc of radius `r` with tolerance `tol`.
368fn arc_segments(r: f32, tol: f32) -> usize {
369    if r <= tol {
370        return 6;
371    }
372    let theta = 2.0 * (1.0 - tol / r).clamp(-1.0, 1.0).acos();
373    if theta <= 1e-3 {
374        return 64;
375    }
376    ((2.0 * PI / theta).ceil() as usize).clamp(8, 512)
377}
378
379/// Adds a polygon, guaranteeing counter-clockwise winding.
380fn push_ccw(mut pts: Vec<Point>, polys: &mut Vec<Vec<Point>>) {
381    if pts.len() < 3 {
382        return;
383    }
384    if signed_area(&pts) < 0.0 {
385        pts.reverse();
386    }
387    polys.push(pts);
388}
389
390fn signed_area(pts: &[Point]) -> f32 {
391    let mut area = 0.0;
392    for i in 0..pts.len() {
393        let a = pts[i];
394        let b = pts[(i + 1) % pts.len()];
395        area += a.cross(b);
396    }
397    area * 0.5
398}
399
400/// Removes consecutive coincident points (and the closing duplicate).
401fn dedupe(points: &[Point], closed: bool) -> Vec<Point> {
402    let mut out: Vec<Point> = Vec::with_capacity(points.len());
403    for &p in points {
404        if out.last().is_none_or(|&l: &Point| l.distance(p) > 1e-4) {
405            out.push(p);
406        }
407    }
408    if closed && out.len() >= 2 && out[0].distance(out[out.len() - 1]) <= 1e-4 {
409        out.pop();
410    }
411    out
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::path::Contour;
418
419    #[test]
420    fn stroke_segment_makes_quad() {
421        let contour = Contour { points: vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0)], closed: false };
422        let polys = build_stroke(&[contour], &Stroke { width: 2.0, ..Stroke::default() }, 0.1);
423        assert!(!polys.is_empty());
424        // the first stamp is a rectangle of 4 points
425        assert_eq!(polys[0].len(), 4);
426    }
427
428    #[test]
429    fn dash_splits_segment_into_runs() {
430        // A line of length 10 with the pattern [2,2] gives runs [0,2],[4,6],[8,10].
431        let contours = vec![Contour {
432            points: vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0)],
433            closed: false,
434        }];
435        let dashed = apply_dash(&contours, &[2.0, 2.0], 0.0);
436        assert_eq!(dashed.len(), 3, "expected three runs");
437        assert!((dashed[0].points[0].x - 0.0).abs() < 1e-4);
438        assert!((dashed[0].points[1].x - 2.0).abs() < 1e-4);
439        assert!((dashed[2].points[0].x - 8.0).abs() < 1e-4);
440        assert!((dashed[2].points[1].x - 10.0).abs() < 1e-4);
441    }
442
443    #[test]
444    fn dash_offset_shifts_phase() {
445        // An offset of 1 within the pattern [2,2]: the line starts in the middle
446        // of a dash, so the first (shortened) run is [0,1], the next is [3,5].
447        let contours = vec![Contour {
448            points: vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0)],
449            closed: false,
450        }];
451        let dashed = apply_dash(&contours, &[2.0, 2.0], 1.0);
452        assert!((dashed[0].points[0].x - 0.0).abs() < 1e-4, "first run from zero");
453        assert!((dashed[0].points[1].x - 1.0).abs() < 1e-4, "shortened to 1");
454        assert!((dashed[1].points[0].x - 3.0).abs() < 1e-4, "second run from 3");
455    }
456
457    #[test]
458    fn build_stroke_dashed_produces_multiple_runs() {
459        let contour = Contour {
460            points: vec![Point::new(0.0, 0.0), Point::new(20.0, 0.0)],
461            closed: false,
462        };
463        let s = Stroke { width: 2.0, dash: vec![4.0, 4.0], ..Stroke::default() };
464        let polys = build_stroke(&[contour], &s, 0.1);
465        // Several runs — noticeably more than a single rectangle.
466        assert!(polys.len() >= 3, "polys={}", polys.len());
467    }
468
469    #[test]
470    fn round_cap_adds_discs() {
471        let contour = Contour { points: vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0)], closed: false };
472        let s = Stroke { width: 4.0, line_cap: LineCap::Round, ..Stroke::default() };
473        let polys = build_stroke(&[contour], &s, 0.1);
474        // a rectangle + two discs
475        assert!(polys.len() >= 3);
476    }
477}