Skip to main content

oxiui_render_soft/
path.rs

1//! 2D path representation with Bézier flattening, fill, and stroke.
2//!
3//! [`Path`] stores a sequence of `PathCmd` commands; [`PathBuilder`] offers
4//! a fluent API for constructing paths. Flattening uses De Casteljau adaptive
5//! subdivision. Fill delegates to [`crate::scanline`]; stroke constructs a
6//! parallel-offset polygon and fills it.
7
8use crate::framebuffer::Framebuffer;
9use crate::scanline::{FillRule, Rasterizer};
10use oxiui_core::Color;
11
12// ---------------------------------------------------------------------------
13// Public types
14// ---------------------------------------------------------------------------
15
16/// A single 2-D point.
17pub type Point = (f32, f32);
18
19/// Stroke join style.
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub enum Join {
22    /// Sharp corner, limited by `miter_limit`.
23    Miter,
24    /// Bevel: flat triangle fills the gap.
25    Bevel,
26    /// Round: circular arc fills the gap.
27    Round,
28}
29
30/// Stroke cap style for open sub-paths.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub enum Cap {
33    /// Flat cap flush with the endpoint.
34    Butt,
35    /// Round cap: semicircle centred on the endpoint.
36    Round,
37    /// Square cap: extends half the line width past the endpoint.
38    Square,
39}
40
41/// Stroke configuration.
42#[derive(Clone, Debug)]
43pub struct StrokeStyle {
44    /// Line width in pixels.
45    pub width: f32,
46    /// Line join style.
47    pub join: Join,
48    /// Line cap style.
49    pub cap: Cap,
50    /// Miter length limit (multiples of half line-width).
51    pub miter_limit: f32,
52}
53
54impl Default for StrokeStyle {
55    fn default() -> Self {
56        Self {
57            width: 1.0,
58            join: Join::Miter,
59            cap: Cap::Butt,
60            miter_limit: 4.0,
61        }
62    }
63}
64
65/// A single path command.
66#[derive(Clone, Debug)]
67enum PathCmd {
68    MoveTo(Point),
69    LineTo(Point),
70    QuadTo(Point, Point),         // (control, end)
71    CubicTo(Point, Point, Point), // (c1, c2, end)
72    Close,
73}
74
75// ---------------------------------------------------------------------------
76// Path
77// ---------------------------------------------------------------------------
78
79/// A 2-D path storing a sequence of draw commands.
80///
81/// Use [`PathBuilder`] (or the inherent builder methods) to populate a path,
82/// then call [`Path::fill`] or [`Path::stroke`] to rasterise it.
83#[derive(Clone, Debug, Default)]
84pub struct Path {
85    cmds: Vec<PathCmd>,
86    fill_rule: FillRule,
87}
88
89impl Path {
90    /// Create an empty path with the default (non-zero) fill rule.
91    pub fn new() -> Self {
92        Self {
93            cmds: Vec::new(),
94            fill_rule: FillRule::default(),
95        }
96    }
97
98    /// Set the fill rule (consumes and returns `self` for chaining).
99    pub fn with_fill_rule(mut self, rule: FillRule) -> Self {
100        self.fill_rule = rule;
101        self
102    }
103
104    /// Move the current point to `p` without drawing.
105    pub fn move_to(&mut self, p: Point) -> &mut Self {
106        self.cmds.push(PathCmd::MoveTo(p));
107        self
108    }
109
110    /// Add a straight line from the current point to `p`.
111    pub fn line_to(&mut self, p: Point) -> &mut Self {
112        self.cmds.push(PathCmd::LineTo(p));
113        self
114    }
115
116    /// Add a quadratic Bézier curve with control point `ctrl` ending at `end`.
117    pub fn quad_to(&mut self, ctrl: Point, end: Point) -> &mut Self {
118        self.cmds.push(PathCmd::QuadTo(ctrl, end));
119        self
120    }
121
122    /// Add a cubic Bézier curve with control points `c1`/`c2` ending at `end`.
123    pub fn cubic_to(&mut self, c1: Point, c2: Point, end: Point) -> &mut Self {
124        self.cmds.push(PathCmd::CubicTo(c1, c2, end));
125        self
126    }
127
128    /// Close the current sub-path (draw a line back to the last `MoveTo`).
129    pub fn close(&mut self) -> &mut Self {
130        self.cmds.push(PathCmd::Close);
131        self
132    }
133
134    // -----------------------------------------------------------------------
135    // Flattening
136    // -----------------------------------------------------------------------
137
138    /// Flatten all sub-paths to sequences of line vertices.
139    ///
140    /// Returns one `Vec<Point>` per sub-path (contour).  The last point of
141    /// each contour is not duplicated at the start; callers should treat the
142    /// contour as closed (connect last → first).
143    ///
144    /// `tolerance` is the maximum chord-distance deviation allowed per Bézier
145    /// segment (in pixels). A value of `0.25` gives visually smooth curves.
146    pub fn flatten(&self, tolerance: f32) -> Vec<Vec<Point>> {
147        let tol = tolerance.max(0.01);
148        let mut contours: Vec<Vec<Point>> = Vec::new();
149        let mut current: Vec<Point> = Vec::new();
150        let mut start: Option<Point> = None;
151        let mut cursor: Point = (0.0, 0.0);
152
153        for cmd in &self.cmds {
154            match *cmd {
155                PathCmd::MoveTo(p) => {
156                    if !current.is_empty() {
157                        contours.push(core::mem::take(&mut current));
158                    }
159                    start = Some(p);
160                    cursor = p;
161                    current.push(p);
162                }
163                PathCmd::LineTo(p) => {
164                    current.push(p);
165                    cursor = p;
166                }
167                PathCmd::QuadTo(ctrl, end) => {
168                    flatten_quad(cursor, ctrl, end, tol, &mut current);
169                    cursor = end;
170                }
171                PathCmd::CubicTo(c1, c2, end) => {
172                    flatten_cubic(cursor, c1, c2, end, tol, &mut current);
173                    cursor = end;
174                }
175                PathCmd::Close => {
176                    if let Some(s) = start {
177                        if let Some(&last) = current.last() {
178                            if dist2(last, s) > f32::EPSILON {
179                                current.push(s);
180                            }
181                        }
182                    }
183                    if !current.is_empty() {
184                        contours.push(core::mem::take(&mut current));
185                    }
186                    if let Some(s) = start {
187                        cursor = s;
188                    }
189                }
190            }
191        }
192        if !current.is_empty() {
193            contours.push(current);
194        }
195        contours
196    }
197
198    // -----------------------------------------------------------------------
199    // Fill
200    // -----------------------------------------------------------------------
201
202    /// Fill this path's interior into `fb` using the configured fill rule.
203    ///
204    /// A single [`Rasterizer`] is created for the duration of this call so
205    /// that scratch buffers are reused across all contours of the path.
206    pub fn fill(&self, fb: &mut Framebuffer, color: Color) {
207        let tolerance = 0.25_f32;
208        let contours = self.flatten(tolerance);
209        let mut ras = Rasterizer::new();
210        for contour in contours {
211            if contour.len() >= 3 {
212                ras.fill_polygon(fb, &contour, color, self.fill_rule, true);
213            }
214        }
215    }
216
217    // -----------------------------------------------------------------------
218    // Stroke
219    // -----------------------------------------------------------------------
220
221    /// Stroke this path into `fb` using the given style.
222    ///
223    /// Each sub-path is stroked independently; closed sub-paths produce
224    /// closed outlines (the cap style is ignored for closed paths).
225    ///
226    /// A single [`Rasterizer`] is created for the duration of this call so
227    /// that scratch buffers are reused across all contours.
228    pub fn stroke(&self, fb: &mut Framebuffer, style: &StrokeStyle, color: Color) {
229        let half = (style.width * 0.5).max(0.5);
230        let tolerance = 0.25_f32;
231        let contours = self.flatten(tolerance);
232        let mut ras = Rasterizer::new();
233        for contour in &contours {
234            if contour.len() < 2 {
235                continue;
236            }
237            stroke_contour_with_ras(fb, contour, half, style, color, &mut ras);
238        }
239    }
240
241    /// Fill this path's interior, clipped to `clip`.
242    ///
243    /// Uses `fill_polygon_clipped` so pixels outside `clip` are never written.
244    /// Anti-aliasing is enabled by default.
245    pub fn fill_clipped(&self, fb: &mut Framebuffer, color: Color, clip: crate::clip::ClipRect) {
246        self.fill_clipped_aa(fb, color, clip, true);
247    }
248
249    /// Stroke this path, clipped to `clip`.
250    ///
251    /// Constructs the stroke polygon and fills it using `fill_polygon_clipped`.
252    pub fn stroke_clipped(
253        &self,
254        fb: &mut Framebuffer,
255        style: &StrokeStyle,
256        color: Color,
257        clip: crate::clip::ClipRect,
258    ) {
259        self.stroke_clipped_aa(fb, style, color, clip, true);
260    }
261
262    /// Fill this path's interior, clipped to `clip`, with explicit AA control.
263    ///
264    /// When `aa` is `false`, edges are aliased (faster; no coverage blending).
265    ///
266    /// A single [`Rasterizer`] is created for the duration of this call so
267    /// that scratch buffers are reused across all contours.
268    pub fn fill_clipped_aa(
269        &self,
270        fb: &mut Framebuffer,
271        color: Color,
272        clip: crate::clip::ClipRect,
273        aa: bool,
274    ) {
275        let tolerance = 0.25_f32;
276        let contours = self.flatten(tolerance);
277        let mut ras = Rasterizer::new();
278        for contour in contours {
279            if contour.len() >= 3 {
280                ras.fill_polygon_clipped(fb, &contour, color, self.fill_rule, aa, clip);
281            }
282        }
283    }
284
285    /// Stroke this path, clipped to `clip`, with explicit AA control.
286    ///
287    /// When `aa` is `false`, the stroke polygon edges are aliased.
288    ///
289    /// A single [`Rasterizer`] is created for the duration of this call so
290    /// that scratch buffers are reused across all contours.
291    pub fn stroke_clipped_aa(
292        &self,
293        fb: &mut Framebuffer,
294        style: &StrokeStyle,
295        color: Color,
296        clip: crate::clip::ClipRect,
297        aa: bool,
298    ) {
299        let half = (style.width * 0.5).max(0.5);
300        let tolerance = 0.25_f32;
301        let contours = self.flatten(tolerance);
302        let mut ras = Rasterizer::new();
303        for contour in &contours {
304            if contour.len() < 2 {
305                continue;
306            }
307            stroke_contour_clipped_inner_with_ras(
308                fb, contour, half, style, color, clip, aa, &mut ras,
309            );
310        }
311    }
312}
313
314// ---------------------------------------------------------------------------
315// PathBuilder — fluent constructor
316// ---------------------------------------------------------------------------
317
318/// Fluent builder for [`Path`].
319///
320/// ```rust
321/// use oxiui_render_soft::path::PathBuilder;
322/// use oxiui_render_soft::FillRule;
323/// let path = PathBuilder::new()
324///     .move_to((10.0, 10.0))
325///     .line_to((50.0, 10.0))
326///     .line_to((50.0, 50.0))
327///     .close()
328///     .fill_rule(FillRule::EvenOdd)
329///     .build();
330/// ```
331#[derive(Clone, Debug, Default)]
332pub struct PathBuilder {
333    path: Path,
334}
335
336impl PathBuilder {
337    /// Create an empty builder.
338    pub fn new() -> Self {
339        Self::default()
340    }
341
342    /// Move to `p`.
343    pub fn move_to(mut self, p: Point) -> Self {
344        self.path.move_to(p);
345        self
346    }
347
348    /// Line to `p`.
349    pub fn line_to(mut self, p: Point) -> Self {
350        self.path.line_to(p);
351        self
352    }
353
354    /// Quadratic Bézier to `end` via `ctrl`.
355    pub fn quad_to(mut self, ctrl: Point, end: Point) -> Self {
356        self.path.quad_to(ctrl, end);
357        self
358    }
359
360    /// Cubic Bézier to `end` via `c1`, `c2`.
361    pub fn cubic_to(mut self, c1: Point, c2: Point, end: Point) -> Self {
362        self.path.cubic_to(c1, c2, end);
363        self
364    }
365
366    /// Close the current sub-path.
367    pub fn close(mut self) -> Self {
368        self.path.close();
369        self
370    }
371
372    /// Set the fill rule.
373    pub fn fill_rule(mut self, rule: FillRule) -> Self {
374        self.path.fill_rule = rule;
375        self
376    }
377
378    /// Consume the builder and return the finished [`Path`].
379    pub fn build(self) -> Path {
380        self.path
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Bézier flattening (De Casteljau)
386// ---------------------------------------------------------------------------
387
388/// Squared Euclidean distance between two points.
389#[inline]
390fn dist2(a: Point, b: Point) -> f32 {
391    let dx = b.0 - a.0;
392    let dy = b.1 - a.1;
393    dx * dx + dy * dy
394}
395
396/// Midpoint of a segment.
397#[inline]
398fn mid(a: Point, b: Point) -> Point {
399    ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5)
400}
401
402/// Adaptive De Casteljau flattening for a quadratic Bézier.
403///
404/// Appends intermediate points to `out`; does NOT re-push `p0`.
405pub fn flatten_quad(p0: Point, p1: Point, p2: Point, tol: f32, out: &mut Vec<Point>) {
406    // Chord-deviation test: if the control point is close enough to the chord, done.
407    let chord_dev = {
408        let mx = (p0.0 + p2.0) * 0.5;
409        let my = (p0.1 + p2.1) * 0.5;
410        let dx = p1.0 - mx;
411        let dy = p1.1 - my;
412        dx * dx + dy * dy
413    };
414    if chord_dev <= tol * tol * 4.0 {
415        // Close enough — emit endpoint.
416        out.push(p2);
417        return;
418    }
419    // Subdivide at t = 0.5.
420    let q0 = mid(p0, p1);
421    let q1 = mid(p1, p2);
422    let r0 = mid(q0, q1);
423    flatten_quad(p0, q0, r0, tol, out);
424    flatten_quad(r0, q1, p2, tol, out);
425}
426
427/// Adaptive De Casteljau flattening for a cubic Bézier.
428///
429/// Appends intermediate points to `out`; does NOT re-push `p0`.
430pub fn flatten_cubic(p0: Point, p1: Point, p2: Point, p3: Point, tol: f32, out: &mut Vec<Point>) {
431    // Chord-deviation test.
432    let chord_dev = {
433        // For cubic: the max deviation from the chord [p0, p3] is bounded by
434        // 3/4 * max(|p1 - chord(t1)|, |p2 - chord(t2)|).
435        // A simpler conservative bound: check both hull diagonals.
436        let d1 = {
437            let mx = (2.0 * p0.0 + p3.0) / 3.0;
438            let my = (2.0 * p0.1 + p3.1) / 3.0;
439            let dx = p1.0 - mx;
440            let dy = p1.1 - my;
441            dx * dx + dy * dy
442        };
443        let d2 = {
444            let mx = (p0.0 + 2.0 * p3.0) / 3.0;
445            let my = (p0.1 + 2.0 * p3.1) / 3.0;
446            let dx = p2.0 - mx;
447            let dy = p2.1 - my;
448            dx * dx + dy * dy
449        };
450        d1.max(d2)
451    };
452    if chord_dev <= tol * tol {
453        out.push(p3);
454        return;
455    }
456    // Subdivide at t = 0.5.
457    let q0 = mid(p0, p1);
458    let q1 = mid(p1, p2);
459    let q2 = mid(p2, p3);
460    let r0 = mid(q0, q1);
461    let r1 = mid(q1, q2);
462    let s0 = mid(r0, r1);
463    flatten_cubic(p0, q0, r0, s0, tol, out);
464    flatten_cubic(s0, r1, q2, p3, tol, out);
465}
466
467// ---------------------------------------------------------------------------
468// Stroke helper
469// ---------------------------------------------------------------------------
470
471/// Compute the unit normal (perpendicular) to a segment, scaled by `half_w`.
472#[inline]
473fn normal(a: Point, b: Point, half_w: f32) -> (f32, f32) {
474    let dx = b.0 - a.0;
475    let dy = b.1 - a.1;
476    let len = (dx * dx + dy * dy).sqrt();
477    if len < f32::EPSILON {
478        return (0.0, half_w);
479    }
480    let nx = -dy / len * half_w;
481    let ny = dx / len * half_w;
482    (nx, ny)
483}
484
485/// Offset a point by a normal vector.
486#[inline]
487fn offset(p: Point, n: (f32, f32)) -> Point {
488    (p.0 + n.0, p.1 + n.1)
489}
490
491/// Offset a point by the negative of a normal vector.
492#[inline]
493fn offset_neg(p: Point, n: (f32, f32)) -> Point {
494    (p.0 - n.0, p.1 - n.1)
495}
496
497/// Stroke a single (possibly closed) contour, filling the resulting polygon
498/// via a caller-supplied `Rasterizer` for scratch reuse.
499fn stroke_contour_with_ras(
500    fb: &mut Framebuffer,
501    pts: &[Point],
502    half_w: f32,
503    style: &StrokeStyle,
504    color: Color,
505    ras: &mut Rasterizer,
506) {
507    let poly = build_stroke_poly(pts, half_w, style);
508    if poly.len() >= 3 {
509        ras.fill_polygon(fb, &poly, color, FillRule::NonZero, true);
510    }
511}
512
513/// Build the parallel-offset stroke polygon for a single contour.
514///
515/// Returns the stroke polygon as a `Vec<Point>` (left side forward + right
516/// side reversed). Returns an empty `Vec` if the contour is too short.
517fn build_stroke_poly(pts: &[Point], half_w: f32, style: &StrokeStyle) -> Vec<Point> {
518    let n = pts.len();
519    if n < 2 {
520        return Vec::new();
521    }
522    let closed = dist2(pts[0], pts[n - 1]) < 1e-4;
523    let effective_n = if closed { n - 1 } else { n };
524    if effective_n < 2 {
525        return Vec::new();
526    }
527
528    let seg_count = effective_n - 1;
529    let mut normals: Vec<(f32, f32)> = Vec::with_capacity(seg_count);
530    for i in 0..seg_count {
531        normals.push(normal(pts[i], pts[i + 1], half_w));
532    }
533
534    let mut left: Vec<Point> = Vec::with_capacity(effective_n + 8);
535    let mut right: Vec<Point> = Vec::with_capacity(effective_n + 8);
536
537    // --- Start cap ---
538    if !closed {
539        let n0 = normals[0];
540        match style.cap {
541            Cap::Butt => {
542                left.push(offset(pts[0], n0));
543                right.push(offset_neg(pts[0], n0));
544            }
545            Cap::Square => {
546                let dir = direction(pts[0], pts[1], half_w);
547                left.push(offset(offset_neg(pts[0], dir), n0));
548                right.push(offset_neg(offset_neg(pts[0], dir), n0));
549            }
550            Cap::Round => {
551                add_round_cap(&mut left, pts[0], n0, true);
552                right.push(offset_neg(pts[0], n0));
553            }
554        }
555    } else {
556        let n_last = normals[seg_count - 1];
557        let n_first = normals[0];
558        if style.join == Join::Round {
559            add_round_join(&mut left, &mut right, pts[0], n_last, n_first);
560        } else {
561            let (jl, jr) = compute_join(pts[0], n_last, n_first, style.join, style.miter_limit);
562            left.push(jl);
563            right.push(jr);
564        }
565    }
566
567    // --- Interior vertices ---
568    for i in 1..effective_n - 1 {
569        let n_prev = normals[i - 1];
570        let n_next = normals[i];
571        match style.join {
572            Join::Round => {
573                add_round_join(&mut left, &mut right, pts[i], n_prev, n_next);
574            }
575            _ => {
576                let (jl, jr) = compute_join(pts[i], n_prev, n_next, style.join, style.miter_limit);
577                left.push(jl);
578                right.push(jr);
579            }
580        }
581    }
582
583    // --- End point ---
584    if !closed {
585        let n_last = normals[seg_count - 1];
586        let end = pts[effective_n - 1];
587        match style.cap {
588            Cap::Butt => {
589                left.push(offset(end, n_last));
590                right.push(offset_neg(end, n_last));
591            }
592            Cap::Square => {
593                let dir = direction(pts[effective_n - 2], end, half_w);
594                left.push(offset(offset(end, dir), n_last));
595                right.push(offset_neg(offset(end, dir), n_last));
596            }
597            Cap::Round => {
598                left.push(offset(end, n_last));
599                add_round_cap(&mut right, end, n_last, false);
600            }
601        }
602    } else {
603        let n_prev = normals[seg_count - 1];
604        let n_first = normals[0];
605        if style.join == Join::Round {
606            add_round_join(&mut left, &mut right, pts[effective_n - 1], n_prev, n_first);
607        } else {
608            let (jl, jr) = compute_join(
609                pts[effective_n - 1],
610                n_prev,
611                n_first,
612                style.join,
613                style.miter_limit,
614            );
615            left.push(jl);
616            right.push(jr);
617        }
618    }
619
620    // Combine left (forward) + right (reversed) into a single polygon.
621    let mut poly: Vec<Point> = Vec::with_capacity(left.len() + right.len());
622    poly.extend_from_slice(&left);
623    for &p in right.iter().rev() {
624        poly.push(p);
625    }
626    poly
627}
628
629/// Stroke a single contour, clipped to `clip`, with explicit AA flag and
630/// scratch-reuse via a caller-provided `Rasterizer`.
631#[allow(clippy::too_many_arguments)]
632fn stroke_contour_clipped_inner_with_ras(
633    fb: &mut Framebuffer,
634    pts: &[Point],
635    half_w: f32,
636    style: &StrokeStyle,
637    color: Color,
638    clip: crate::clip::ClipRect,
639    aa: bool,
640    ras: &mut Rasterizer,
641) {
642    let poly = build_stroke_poly(pts, half_w, style);
643    if poly.len() >= 3 {
644        ras.fill_polygon_clipped(fb, &poly, color, FillRule::NonZero, aa, clip);
645    }
646}
647
648/// Compute the direction unit vector of a segment, scaled by `scale`.
649#[inline]
650fn direction(a: Point, b: Point, scale: f32) -> (f32, f32) {
651    let dx = b.0 - a.0;
652    let dy = b.1 - a.1;
653    let len = (dx * dx + dy * dy).sqrt();
654    if len < f32::EPSILON {
655        return (scale, 0.0);
656    }
657    (dx / len * scale, dy / len * scale)
658}
659
660/// Compute the left and right join vertices at an interior joint, using the
661/// configured join style.
662///
663/// Returns `(left_offset, right_offset)` where "left" is the offset in the
664/// positive normal direction and "right" is in the negative direction.
665///
666/// Note: `Join::Round` is handled separately in `stroke_contour` via
667/// `add_round_join` (which inserts a full arc fan). This function is used for
668/// `Miter` and `Bevel` joins only.
669fn compute_join(
670    pt: Point,
671    n_prev: (f32, f32),
672    n_next: (f32, f32),
673    join: Join,
674    miter_limit: f32,
675) -> (Point, Point) {
676    match join {
677        Join::Bevel | Join::Round => (offset(pt, n_next), offset_neg(pt, n_next)),
678        Join::Miter => miter_join(pt, n_prev, n_next, miter_limit),
679    }
680}
681
682/// Compute a miter join with limit fallback to bevel.
683fn miter_join(
684    pt: Point,
685    n_prev: (f32, f32),
686    n_next: (f32, f32),
687    miter_limit: f32,
688) -> (Point, Point) {
689    // Average the two normals (both point to the "outside" of their segments).
690    let avg_x = (n_prev.0 + n_next.0) * 0.5;
691    let avg_y = (n_prev.1 + n_next.1) * 0.5;
692    let len_sq = avg_x * avg_x + avg_y * avg_y;
693    if len_sq < f32::EPSILON {
694        // Normals cancel (180° turn) → bevel.
695        return (offset(pt, n_next), offset_neg(pt, n_next));
696    }
697    // The miter offset length equals half_w / |avg| (from geometry).
698    // |avg|² = len_sq; we want to scale avg so that |result| = half_w/sin(θ/2)
699    // where the half_w is already encoded in n_prev/n_next magnitude.
700    let scale = 1.0 / len_sq;
701    // half_w magnitude from n_prev.
702    let half_w_sq = n_prev.0 * n_prev.0 + n_prev.1 * n_prev.1;
703    let miter_len_sq = half_w_sq * scale;
704    // Miter limit check: if miter_len / half_w > miter_limit → bevel.
705    if miter_len_sq > miter_limit * miter_limit * half_w_sq {
706        return (offset(pt, n_next), offset_neg(pt, n_next));
707    }
708    let mx = avg_x * scale * half_w_sq;
709    let my = avg_y * scale * half_w_sq;
710    let left = (pt.0 + mx, pt.1 + my);
711    let right = (pt.0 - mx, pt.1 - my);
712    (left, right)
713}
714
715/// Insert arc-fan points for a round join at vertex `pt`.
716///
717/// The join fans between `n_prev` (end of the previous segment's normal) and
718/// `n_next` (start of the next segment's normal). The outer side receives the
719/// fan; the inner side receives a single miter-like point at the intersection.
720fn add_round_join(
721    left: &mut Vec<Point>,
722    right: &mut Vec<Point>,
723    pt: Point,
724    n_prev: (f32, f32),
725    n_next: (f32, f32),
726) {
727    let half_w = (n_prev.0 * n_prev.0 + n_prev.1 * n_prev.1).sqrt();
728    if half_w < f32::EPSILON {
729        return;
730    }
731    // Cross product sign determines whether the turn is left or right.
732    // n_prev and n_next are "left" normals (perpendicular pointing left of travel).
733    // cross = n_prev.x * n_next.y - n_prev.y * n_next.x
734    let cross = n_prev.0 * n_next.1 - n_prev.1 * n_next.0;
735
736    // Start and end angles of the arc.
737    let start_angle = n_prev.1.atan2(n_prev.0);
738    let end_angle = n_next.1.atan2(n_next.0);
739
740    // Sweep the shorter arc.
741    let mut sweep = end_angle - start_angle;
742    // Normalise sweep to [-π, π].
743    while sweep > std::f32::consts::PI {
744        sweep -= 2.0 * std::f32::consts::PI;
745    }
746    while sweep < -std::f32::consts::PI {
747        sweep += 2.0 * std::f32::consts::PI;
748    }
749
750    const STEPS: usize = 8;
751    let fan_pts: Vec<Point> = (0..=STEPS)
752        .map(|step| {
753            let a = start_angle + sweep * (step as f32 / STEPS as f32);
754            (pt.0 + a.cos() * half_w, pt.1 + a.sin() * half_w)
755        })
756        .collect();
757
758    if cross >= 0.0 {
759        // Left turn: the left side is the outer side (gets the fan).
760        for &p in &fan_pts {
761            left.push(p);
762        }
763        // Right (inner) side: single bevel point.
764        right.push(offset_neg(pt, n_next));
765    } else {
766        // Right turn: the right side is the outer (gets the negated fan).
767        left.push(offset(pt, n_next));
768        for &p in fan_pts.iter().rev() {
769            // Mirror the fan to the right (negative normal).
770            let dx = p.0 - pt.0;
771            let dy = p.1 - pt.1;
772            right.push((pt.0 - dx, pt.1 - dy));
773        }
774    }
775}
776
777/// Add semicircle fan points for a round cap. Appends to `target`.
778fn add_round_cap(target: &mut Vec<Point>, center: Point, n: (f32, f32), forward: bool) {
779    // Approximate semicircle with 8 steps.
780    const STEPS: usize = 8;
781    let (nx, ny) = n;
782    let half_w = (nx * nx + ny * ny).sqrt();
783    if half_w < f32::EPSILON {
784        return;
785    }
786    let ux = nx / half_w;
787    let uy = ny / half_w;
788    // Start angle: the normal direction (pointing left of travel).
789    // We sweep 180° (π) for the cap.
790    let start_angle = uy.atan2(ux);
791    let sign = if forward { 1.0_f32 } else { -1.0_f32 };
792    for i in 0..=STEPS {
793        let a = start_angle + sign * std::f32::consts::PI * (i as f32 / STEPS as f32);
794        let px = center.0 + a.cos() * half_w;
795        let py = center.1 + a.sin() * half_w;
796        target.push((px, py));
797    }
798}
799
800// ---------------------------------------------------------------------------
801// Convenience flattening public API
802// ---------------------------------------------------------------------------
803
804/// Flatten a quadratic Bézier into a sequence of points (including endpoints).
805pub fn flatten_quad_bezier(p0: Point, p1: Point, p2: Point, tolerance: f32) -> Vec<Point> {
806    let mut pts = vec![p0];
807    flatten_quad(p0, p1, p2, tolerance.max(0.01), &mut pts);
808    pts
809}
810
811/// Flatten a cubic Bézier into a sequence of points (including endpoints).
812pub fn flatten_cubic_bezier(
813    p0: Point,
814    p1: Point,
815    p2: Point,
816    p3: Point,
817    tolerance: f32,
818) -> Vec<Point> {
819    let mut pts = vec![p0];
820    flatten_cubic(p0, p1, p2, p3, tolerance.max(0.01), &mut pts);
821    pts
822}
823
824// ---------------------------------------------------------------------------
825// Tests
826// ---------------------------------------------------------------------------
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use crate::framebuffer::Framebuffer;
832
833    fn fresh(w: u32, h: u32) -> Framebuffer {
834        Framebuffer::with_fill(w, h, Color(0, 0, 0, 255))
835    }
836
837    #[test]
838    fn bezier_flatten_endpoints() {
839        // Flattened cubic must start at p0 and end at p3.
840        let p0 = (0.0f32, 0.0);
841        let p1 = (10.0, 20.0);
842        let p2 = (20.0, -10.0);
843        let p3 = (30.0, 0.0);
844        let pts = flatten_cubic_bezier(p0, p1, p2, p3, 0.25);
845        assert!(pts.len() >= 2);
846        let first = pts[0];
847        let last = *pts.last().expect("at least one point");
848        assert!((first.0 - p0.0).abs() < 0.01 && (first.1 - p0.1).abs() < 0.01);
849        assert!((last.0 - p3.0).abs() < 0.01 && (last.1 - p3.1).abs() < 0.01);
850    }
851
852    #[test]
853    fn quad_flatten_endpoints() {
854        let p0 = (0.0f32, 0.0);
855        let p1 = (5.0, 10.0);
856        let p2 = (10.0, 0.0);
857        let pts = flatten_quad_bezier(p0, p1, p2, 0.25);
858        let last = *pts.last().expect("at least one point");
859        assert!((last.0 - p2.0).abs() < 0.01 && (last.1 - p2.1).abs() < 0.01);
860    }
861
862    #[test]
863    fn path_fill_triangle() {
864        let mut fb = fresh(20, 20);
865        let mut path = Path::new();
866        path.move_to((0.0, 0.0))
867            .line_to((20.0, 0.0))
868            .line_to((10.0, 20.0))
869            .close();
870        path.fill(&mut fb, Color(255, 0, 0, 255));
871        // Centre of triangle should be painted.
872        let (r, _, _, _) = fb.get_rgba(10, 10).unwrap_or((0, 0, 0, 0));
873        assert!(r > 0, "centre should be painted");
874    }
875
876    #[test]
877    fn path_fill_rule_cases() {
878        // Self-intersecting 5-pointed star via Path:
879        // EvenOdd leaves the center pentagon as a hole; NonZero fills it.
880        // Use a large star so arms are wide enough to hit individual pixels.
881        let cx = 35.0f32;
882        let cy = 35.0f32;
883        let r = 30.0f32;
884        let star_pts: Vec<(f32, f32)> = (0..5)
885            .map(|i| {
886                let angle = std::f32::consts::PI * (2.0 * (2 * i) as f32 / 5.0 - 0.5);
887                (cx + r * angle.cos(), cy + r * angle.sin())
888            })
889            .collect();
890
891        let build_star = |rule: FillRule| {
892            let mut p = Path::new().with_fill_rule(rule);
893            p.move_to(star_pts[0]);
894            for &pt in &star_pts[1..] {
895                p.line_to(pt);
896            }
897            p.close();
898            p
899        };
900
901        let path_eo = build_star(FillRule::EvenOdd);
902        let path_nz = build_star(FillRule::NonZero);
903
904        let mut fb_eo = fresh(70, 70);
905        let mut fb_nz = fresh(70, 70);
906        path_eo.fill(&mut fb_eo, Color(255, 0, 0, 255));
907        path_nz.fill(&mut fb_nz, Color(255, 0, 0, 255));
908
909        // Both should paint a star arm.
910        let (r_eo_tip, _, _, _) = fb_eo.get_rgba(35, 8).unwrap_or((0, 0, 0, 0));
911        assert!(r_eo_tip > 0, "EvenOdd: star arm should be painted");
912
913        // NonZero: center should be filled.
914        let (r_nz_ctr, _, _, _) = fb_nz.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
915        assert!(r_nz_ctr > 0, "NonZero: star center should be filled");
916
917        // EvenOdd: center should be a hole.
918        let (r_eo_ctr, _, _, _) = fb_eo.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
919        assert_eq!(
920            r_eo_ctr, 0,
921            "EvenOdd: star center should be a hole (r={r_eo_ctr})"
922        );
923    }
924
925    #[test]
926    fn path_builder_works() {
927        let path = PathBuilder::new()
928            .move_to((0.0, 0.0))
929            .line_to((10.0, 0.0))
930            .line_to((5.0, 10.0))
931            .close()
932            .build();
933        let mut fb = fresh(15, 15);
934        path.fill(&mut fb, Color(0, 255, 0, 255));
935        let (_, g, _, _) = fb.get_rgba(5, 3).unwrap_or((0, 0, 0, 0));
936        assert!(g > 0, "builder path: interior should be green");
937    }
938
939    #[test]
940    fn path_stroke_produces_pixels() {
941        let mut fb = fresh(30, 30);
942        let mut path = Path::new();
943        path.move_to((5.0, 15.0)).line_to((25.0, 15.0));
944        let style = StrokeStyle {
945            width: 4.0,
946            join: Join::Miter,
947            cap: Cap::Butt,
948            miter_limit: 4.0,
949        };
950        path.stroke(&mut fb, &style, Color(0, 0, 255, 255));
951        // Some pixel along the stroke should be painted blue.
952        let mut found = false;
953        for x in 0..30 {
954            let (_, _, b, _) = fb.get_rgba(x, 15).unwrap_or((0, 0, 0, 0));
955            if b > 0 {
956                found = true;
957                break;
958            }
959        }
960        assert!(found, "stroke should produce blue pixels along y=15");
961    }
962
963    #[test]
964    fn round_join_differs_from_bevel() {
965        // Verify that Round join produces a distinct rendering from Bevel at a 90° corner.
966        // Round inserts an arc fan; Bevel cuts with a flat edge. The two produce different
967        // pixel distributions in the corner region.
968        let mut fb_round = fresh(40, 40);
969        let mut fb_bevel = fresh(40, 40);
970
971        let mut path_r = Path::new();
972        path_r
973            .move_to((5.0, 20.0))
974            .line_to((20.0, 20.0))
975            .line_to((20.0, 5.0));
976
977        let mut path_b = Path::new();
978        path_b
979            .move_to((5.0, 20.0))
980            .line_to((20.0, 20.0))
981            .line_to((20.0, 5.0));
982
983        let style_round = StrokeStyle {
984            width: 6.0,
985            join: Join::Round,
986            cap: Cap::Butt,
987            miter_limit: 4.0,
988        };
989        let style_bevel = StrokeStyle {
990            width: 6.0,
991            join: Join::Bevel,
992            cap: Cap::Butt,
993            miter_limit: 4.0,
994        };
995
996        path_r.stroke(&mut fb_round, &style_round, Color(255, 0, 0, 255));
997        path_b.stroke(&mut fb_bevel, &style_bevel, Color(255, 0, 0, 255));
998
999        // Both should paint some pixels.
1000        let count = |fb: &Framebuffer| -> u32 {
1001            (0..40u32)
1002                .flat_map(|y| (0..40u32).map(move |x| (x, y)))
1003                .filter(|&(x, y)| fb.get_rgba(x, y).is_some_and(|(r, _, _, _)| r > 0))
1004                .count() as u32
1005        };
1006        let round_px = count(&fb_round);
1007        let bevel_px = count(&fb_bevel);
1008        assert!(
1009            round_px > 0,
1010            "Round join should produce some pixels (got {round_px})"
1011        );
1012        assert!(
1013            bevel_px > 0,
1014            "Bevel join should produce some pixels (got {bevel_px})"
1015        );
1016        // The pixel counts differ (different corner treatment).
1017        assert_ne!(
1018            round_px, bevel_px,
1019            "Round and Bevel joins should differ in pixel count (both={round_px})"
1020        );
1021    }
1022
1023    #[test]
1024    fn miter_limit_prevents_spike() {
1025        // A very acute join should fall back to bevel (no infinite spike).
1026        // We just verify the function doesn't panic and returns finite values.
1027        let pt = (10.0f32, 10.0);
1028        let n_prev = (0.0f32, 2.0); // pointing up
1029        let n_next = (0.0f32, -2.0); // pointing down (180° → degenerate)
1030        let (left, right) = miter_join(pt, n_prev, n_next, 4.0);
1031        assert!(left.0.is_finite() && left.1.is_finite());
1032        assert!(right.0.is_finite() && right.1.is_finite());
1033    }
1034}