Skip to main content

renamite_geometry/
lib.rs

1//! Vector path geometry. Document stores editable anchors; `kurbo::BezPath` is
2//! the render/hit-test/export form only.
3
4pub mod pucker_bloat;
5pub mod zigzag;
6pub use pucker_bloat::{pucker_bloat_path, pucker_bloat_vector_path};
7pub use zigzag::zigzag_path;
8
9use kurbo::ParamCurveNearest;
10pub use kurbo::{Affine, BezPath, CubicBez, PathEl, Point, Rect, Shape as KurboShape, Vec2};
11
12use glam::DVec2;
13
14/// Validate a dash pattern before passing it to Kurbo.
15///
16/// Returns `None` for:
17/// - empty patterns,
18/// - negative/non-finite entries,
19/// - all-zero patterns.
20///
21/// Mixed zero/nonzero patterns are retained. Kurbo handles odd-length
22/// patterns according to SVG semantics.
23pub fn normalize_dash_pattern(pattern: &[f64]) -> Option<Vec<f64>> {
24    if pattern.is_empty()
25        || pattern.iter().any(|x| !x.is_finite() || *x < 0.0)
26        || pattern.iter().sum::<f64>() <= 1e-9
27    {
28        return None;
29    }
30
31    Some(pattern.to_vec())
32}
33
34/// Apply a stroke dash pattern to `path`.
35///
36/// The returned path consists of open subpaths representing visible dashes.
37/// Each subpath is subsequently capped by the stroke tessellator.
38///
39/// `None` means the dash settings are invalid or effectively disabled, so the
40/// caller should render the original solid path.
41pub fn dash_bez_path(path: &BezPath, pattern: &[f64], offset: f64) -> Option<BezPath> {
42    let pattern = normalize_dash_pattern(pattern)?;
43
44    if !offset.is_finite() {
45        return None;
46    }
47
48    let elements = kurbo::dash(path.elements().iter().copied(), offset, &pattern).collect();
49
50    Some(BezPath::from_vec(elements))
51}
52
53#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
54pub struct VectorPath {
55    pub anchors: Vec<Anchor>,
56    pub closed: bool,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
60pub struct Anchor {
61    pub pos: DVec2,
62    pub tan_in: DVec2,  // relative to pos
63    pub tan_out: DVec2, // relative to pos
64    pub mode: TangentMode,
65}
66
67impl Anchor {
68    pub fn corner(pos: DVec2) -> Self {
69        Self {
70            pos,
71            tan_in: DVec2::ZERO,
72            tan_out: DVec2::ZERO,
73            mode: TangentMode::Corner,
74        }
75    }
76    pub fn symmetric(pos: DVec2, tan_out: DVec2) -> Self {
77        Self {
78            pos,
79            tan_in: -tan_out,
80            tan_out,
81            mode: TangentMode::Symmetric,
82        }
83    }
84}
85
86/// Glaxnimate 0.6: Alt+click cycles modes; Corner->Smooth synthesizes tangents.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
88pub enum TangentMode {
89    Corner,
90    Smooth,
91    Symmetric,
92}
93
94impl TangentMode {
95    pub fn cycled(self) -> Self {
96        match self {
97            TangentMode::Corner => TangentMode::Smooth,
98            TangentMode::Smooth => TangentMode::Symmetric,
99            TangentMode::Symmetric => TangentMode::Corner,
100        }
101    }
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum BooleanOp {
106    Union,
107    Intersection,
108    Difference,
109    Xor,
110}
111
112/// One anchor edit. `Insert` exists so `Delete` has an exact inverse.
113#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
114pub enum AnchorEdit {
115    SetPos {
116        index: usize,
117        pos: DVec2,
118    },
119    SetTanIn {
120        index: usize,
121        tan: DVec2,
122    },
123    SetTanOut {
124        index: usize,
125        tan: DVec2,
126    },
127    SetMode {
128        index: usize,
129        mode: TangentMode,
130        /// Exact tangents to restore. `None` = legacy files: use old
131        /// synthesize-if-zero behavior. New code always writes `Some`.
132        #[serde(default)]
133        tan_in: Option<DVec2>,
134        #[serde(default)]
135        tan_out: Option<DVec2>,
136    },
137    Delete {
138        index: usize,
139    },
140    Insert {
141        index: usize,
142        anchor: Anchor,
143    },
144    SetClosed {
145        closed: bool,
146    },
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub enum PathHit {
151    OnPath,
152    Inside,
153}
154
155#[derive(Clone, Copy, Debug, thiserror::Error)]
156pub enum GeometryError {
157    #[error("segment index {0} out of range")]
158    SegmentOutOfRange(usize),
159    #[error("anchor index {0} out of range")]
160    AnchorOutOfRange(usize),
161    #[error("split parameter {0} is not finite or outside [0,1]")]
162    InvalidSplitParam(f64),
163}
164
165fn pt(v: DVec2) -> Point {
166    Point::new(v.x, v.y)
167}
168
169impl VectorPath {
170    pub fn segment_count(&self) -> usize {
171        let n = self.anchors.len();
172        if self.closed { n } else { n.saturating_sub(1) }
173    }
174
175    pub fn to_bez_path(&self) -> BezPath {
176        let mut p = BezPath::new();
177        let n = self.anchors.len();
178        if n == 0 {
179            return p;
180        }
181        p.move_to(pt(self.anchors[0].pos));
182        for i in 0..self.segment_count() {
183            let a = &self.anchors[i];
184            let b = &self.anchors[(i + 1) % n];
185            p.curve_to(pt(a.pos + a.tan_out), pt(b.pos + b.tan_in), pt(b.pos));
186        }
187        if self.closed {
188            p.close_path();
189        }
190        p
191    }
192
193    pub fn from_bez_path(path: &BezPath) -> Self {
194        let mut out = VectorPath::default();
195        let mut start = DVec2::ZERO;
196        for el in path.elements() {
197            match *el {
198                PathEl::MoveTo(p) => {
199                    let v = DVec2::new(p.x, p.y);
200                    start = v;
201                    out.anchors.push(Anchor::corner(v));
202                }
203                PathEl::LineTo(p) => out.anchors.push(Anchor::corner(DVec2::new(p.x, p.y))),
204                PathEl::QuadTo(q1, q2) => {
205                    // elevate quad to cubic
206                    let prev = out.anchors.last().map(|a| a.pos).unwrap_or_default();
207                    let q1 = DVec2::new(q1.x, q1.y);
208                    let end = DVec2::new(q2.x, q2.y);
209                    let c1 = prev + (q1 - prev) * (2.0 / 3.0);
210                    let c2 = end + (q1 - end) * (2.0 / 3.0);
211                    if let Some(last) = out.anchors.last_mut() {
212                        last.tan_out = c1 - last.pos;
213                    }
214                    let mut a = Anchor::corner(end);
215                    a.tan_in = c2 - end;
216                    out.anchors.push(a);
217                }
218                PathEl::CurveTo(c1, c2, p) => {
219                    let (c1, c2, end) = (
220                        DVec2::new(c1.x, c1.y),
221                        DVec2::new(c2.x, c2.y),
222                        DVec2::new(p.x, p.y),
223                    );
224                    if let Some(last) = out.anchors.last_mut() {
225                        last.tan_out = c1 - last.pos;
226                    }
227                    let mut a = Anchor::corner(end);
228                    a.tan_in = c2 - end;
229                    out.anchors.push(a);
230                }
231                PathEl::ClosePath => {
232                    out.closed = true;
233                    // merge duplicated endpoint back into the first anchor
234                    if out.anchors.len() >= 2 {
235                        let last = *out.anchors.last().unwrap();
236                        if (last.pos - start).length_squared() < 1e-12 {
237                            out.anchors[0].tan_in = last.tan_in;
238                            out.anchors.pop();
239                        }
240                    }
241                }
242            }
243        }
244        for a in &mut out.anchors {
245            a.mode = detect_mode(a.tan_in, a.tan_out);
246        }
247        out
248    }
249
250    /// Return `(segment_index, t_param, distance)` for the nearest cubic segment.
251    pub fn nearest_segment(&self, point: DVec2) -> Option<(usize, f64, f64)> {
252        if self.anchors.len() < 2 {
253            return None;
254        }
255
256        let q = pt(point);
257        let n = self.anchors.len();
258        let seg_count = self.segment_count();
259
260        let mut best_seg = 0usize;
261        let mut best_t = 0.0;
262        let mut best_dist = f64::MAX;
263
264        for i in 0..seg_count {
265            let a = &self.anchors[i];
266            let b = &self.anchors[(i + 1) % n];
267            let cubic = CubicBez::new(
268                pt(a.pos),
269                pt(a.pos + a.tan_out),
270                pt(b.pos + b.tan_in),
271                pt(b.pos),
272            );
273            let hit = cubic.nearest(q, 1e-6);
274            let dist = hit.distance_sq.sqrt();
275            if dist < best_dist {
276                best_seg = i;
277                best_t = hit.t;
278                best_dist = dist;
279            }
280        }
281
282        Some((best_seg, best_t, best_dist))
283    }
284
285    pub fn hit_test(&self, p: DVec2, tol: f64) -> Option<PathHit> {
286        let path = self.to_bez_path();
287        let q = pt(p);
288        let mut best_sq = f64::MAX;
289        for seg in path.segments() {
290            best_sq = best_sq.min(seg.nearest(q, 1e-6).distance_sq);
291        }
292        if best_sq.sqrt() <= tol {
293            return Some(PathHit::OnPath);
294        }
295        if self.closed && path.contains(q) {
296            return Some(PathHit::Inside);
297        }
298        None
299    }
300
301    /// De Casteljau split of segment `seg` at parameter `t`. New anchor is Smooth.
302    pub fn insert_anchor_at(&mut self, seg: usize, t: f64) -> Result<(), GeometryError> {
303        if seg >= self.segment_count() {
304            return Err(GeometryError::SegmentOutOfRange(seg));
305        }
306        if !t.is_finite() || t < 0.0 || t > 1.0 {
307            return Err(GeometryError::InvalidSplitParam(t));
308        }
309        let n = self.anchors.len();
310        let (i, j) = (seg, (seg + 1) % n);
311        let a = self.anchors[i];
312        let b = self.anchors[j];
313        let (p0, p1, p2, p3) = (a.pos, a.pos + a.tan_out, b.pos + b.tan_in, b.pos);
314        let q0 = p0.lerp(p1, t);
315        let q1 = p1.lerp(p2, t);
316        let q2 = p2.lerp(p3, t);
317        let r0 = q0.lerp(q1, t);
318        let r1 = q1.lerp(q2, t);
319        let s = r0.lerp(r1, t);
320        self.anchors[i].tan_out = q0 - p0;
321        self.anchors[j].tan_in = q2 - p3;
322        self.anchors.insert(
323            i + 1,
324            Anchor {
325                pos: s,
326                tan_in: r0 - s,
327                tan_out: r1 - s,
328                mode: TangentMode::Smooth,
329            },
330        );
331        Ok(())
332    }
333
334    /// Round every Corner anchor by pulling back `radius` along both adjacent
335    /// edges and joining with a smooth curve (quarter-circle-ish cubic
336    /// approximation, k=0.5523 scaled by the pullback distance).
337    pub fn round_corners(&self, radius: f64) -> VectorPath {
338        if radius <= 1e-9 || self.anchors.len() < 3 {
339            return self.clone();
340        }
341        let n = self.anchors.len();
342        let seg_count = if self.closed { n } else { n.saturating_sub(1) };
343        if seg_count < 2 {
344            return self.clone();
345        }
346
347        let mut out = Vec::with_capacity(n * 2);
348        for i in 0..n {
349            let a = self.anchors[i];
350            if a.mode != TangentMode::Corner {
351                out.push(a);
352                continue;
353            }
354            // Skip endpoints of an open path - nothing to round into.
355            let has_prev = self.closed || i > 0;
356            let has_next = self.closed || i + 1 < n;
357            if !has_prev || !has_next {
358                out.push(a);
359                continue;
360            }
361            let prev = self.anchors[(i + n - 1) % n];
362            let next = self.anchors[(i + 1) % n];
363
364            let to_prev = prev.pos - a.pos;
365            let to_next = next.pos - a.pos;
366            let (len_prev, len_next) = (to_prev.length(), to_next.length());
367            if len_prev < 1e-9 || len_next < 1e-9 {
368                out.push(a);
369                continue;
370            }
371            // Cap pullback at 45% of the shorter adjacent edge so two rounded
372            // corners on a short edge can't cross each other.
373            let r = radius.min(len_prev * 0.45).min(len_next * 0.45);
374            let dir_prev = to_prev / len_prev;
375            let dir_next = to_next / len_next;
376
377            let p_in = a.pos + dir_prev * r; // pullback toward prev
378            let p_out = a.pos + dir_next * r; // pullback toward next
379
380            // Cubic handle length for a circular-ish arc (standard
381            // 4/3*tan(θ/4) ≈ 0.5523 for a quarter turn).
382            const K: f64 = 0.5523;
383            out.push(Anchor {
384                pos: p_in,
385                tan_in: DVec2::ZERO, // outer side of the corner stays sharp
386                tan_out: -dir_prev * (r * K),
387                mode: TangentMode::Smooth,
388            });
389            out.push(Anchor {
390                pos: p_out,
391                tan_in: -dir_next * (r * K),
392                tan_out: DVec2::ZERO,
393                mode: TangentMode::Smooth,
394            });
395        }
396
397        VectorPath {
398            anchors: out,
399            closed: self.closed,
400        }
401    }
402
403    /// Reverse direction (Trim Path needs this). Swaps in/out tangents.
404    pub fn reverse(&mut self) {
405        self.anchors.reverse();
406        for a in &mut self.anchors {
407            std::mem::swap(&mut a.tan_in, &mut a.tan_out);
408        }
409    }
410
411    /// Apply an edit and return its exact inverse (None if out of range).
412    pub fn apply_edit(&mut self, edit: &AnchorEdit) -> Option<AnchorEdit> {
413        use AnchorEdit::*;
414        match edit {
415            SetPos { index, pos } => {
416                let a = self.anchors.get_mut(*index)?;
417                let inv = SetPos {
418                    index: *index,
419                    pos: a.pos,
420                };
421                a.pos = *pos;
422                Some(inv)
423            }
424            SetTanIn { index, tan } => {
425                let a = self.anchors.get_mut(*index)?;
426                let inv = SetTanIn {
427                    index: *index,
428                    tan: a.tan_in,
429                };
430                a.tan_in = *tan;
431                if a.mode == TangentMode::Symmetric {
432                    a.tan_out = -*tan;
433                }
434                Some(inv)
435            }
436            SetTanOut { index, tan } => {
437                let a = self.anchors.get_mut(*index)?;
438                let inv = SetTanOut {
439                    index: *index,
440                    tan: a.tan_out,
441                };
442                a.tan_out = *tan;
443                if a.mode == TangentMode::Symmetric {
444                    a.tan_in = -*tan;
445                }
446                Some(inv)
447            }
448            SetMode {
449                index,
450                mode,
451                tan_in,
452                tan_out,
453            } => {
454                let a = self.anchors.get_mut(*index)?;
455                let inv = SetMode {
456                    index: *index,
457                    mode: a.mode,
458                    tan_in: Some(a.tan_in),
459                    tan_out: Some(a.tan_out),
460                };
461                if let (Some(ti), Some(to)) = (*tan_in, *tan_out) {
462                    a.mode = *mode;
463                    a.tan_in = ti;
464                    a.tan_out = to;
465                    return Some(inv);
466                }
467                a.mode = *mode;
468                if *mode != TangentMode::Corner
469                    && a.tan_in.length_squared() < 1e-12
470                    && a.tan_out.length_squared() < 1e-12
471                {
472                    a.tan_out = DVec2::new(10.0, 0.0);
473                    a.tan_in = -a.tan_out;
474                }
475                Some(inv)
476            }
477            Delete { index } => {
478                if *index >= self.anchors.len() {
479                    return None;
480                }
481                let a = self.anchors.remove(*index);
482                Some(Insert {
483                    index: *index,
484                    anchor: a,
485                })
486            }
487            Insert { index, anchor } => {
488                if *index > self.anchors.len() {
489                    return None;
490                }
491                self.anchors.insert(*index, *anchor);
492                Some(Delete { index: *index })
493            }
494            SetClosed { closed } => {
495                let inv = SetClosed {
496                    closed: self.closed,
497                };
498                self.closed = *closed;
499                Some(inv)
500            }
501        }
502    }
503}
504
505fn detect_mode(tin: DVec2, tout: DVec2) -> TangentMode {
506    let (li, lo) = (tin.length(), tout.length());
507    if li < 1e-9 || lo < 1e-9 {
508        return TangentMode::Corner;
509    }
510    let cross = tin.x * tout.y - tin.y * tout.x;
511    let colinear_opposed = cross.abs() <= 1e-6 * li * lo && tin.dot(tout) < 0.0;
512    if !colinear_opposed {
513        TangentMode::Corner
514    } else if (li - lo).abs() < 1e-6 {
515        TangentMode::Symmetric
516    } else {
517        TangentMode::Smooth
518    }
519}
520
521/// Errors from boolean operations and stroke expansion.
522#[derive(Debug, thiserror::Error)]
523pub enum PathOpError {
524    #[error("path operation requires closed contours")]
525    OpenPath,
526    #[error("path operation produced no geometry")]
527    Empty,
528    #[error("boolean operation failed: {0}")]
529    Boolean(#[from] linesweeper::Error),
530}
531
532fn map_boolean_op(op: BooleanOp) -> linesweeper::BinaryOp {
533    match op {
534        BooleanOp::Union => linesweeper::BinaryOp::Union,
535        BooleanOp::Intersection => linesweeper::BinaryOp::Intersection,
536        BooleanOp::Difference => linesweeper::BinaryOp::Difference,
537        BooleanOp::Xor => linesweeper::BinaryOp::Xor,
538    }
539}
540
541/// Concatenate closed `contours` into one multi-subpath `BezPath`, suitable
542/// as an input to [`boolean_bez`].
543pub fn contours_to_bez(contours: &[VectorPath]) -> BezPath {
544    let mut out = BezPath::new();
545    for contour in contours {
546        out.extend(contour.to_bez_path().elements().iter().copied());
547    }
548    out
549}
550
551/// Linesweeper-backed boolean op on raw Bézier outlines.
552///
553/// Unlike [`boolean_op`], both sides may already be compound (multi-subpath)
554/// outlines, so folding N selected shapes never discards intermediate holes or
555/// disjoint pieces.
556pub fn boolean_bez(
557    a: &BezPath,
558    b: &BezPath,
559    op: BooleanOp,
560) -> Result<Vec<VectorPath>, PathOpError> {
561    let contours =
562        linesweeper::binary_op(a, b, linesweeper::FillRule::NonZero, map_boolean_op(op))?;
563
564    Ok(contours
565        .contours()
566        .filter_map(|contour| {
567            let path = VectorPath::from_bez_path(&contour.path);
568            (path.closed && path.anchors.len() >= 3).then_some(path)
569        })
570        .collect())
571}
572
573/// Boolean op between two single-contour paths. Multi-contour inputs should
574/// use [`boolean_bez`] via [`contours_to_bez`] so holes survive the fold.
575pub fn boolean_op(
576    a: &VectorPath,
577    b: &VectorPath,
578    op: BooleanOp,
579) -> Result<Vec<VectorPath>, PathOpError> {
580    if !a.closed || !b.closed {
581        return Err(PathOpError::OpenPath);
582    }
583    boolean_bez(&a.to_bez_path(), &b.to_bez_path(), op)
584}
585
586/// Split a multi-subpath `BezPath` into one [`VectorPath`] per subpath
587/// (`MoveTo` .. next `MoveTo`). Subpaths with fewer than two anchors are
588/// dropped; open subpaths stay open.
589pub fn split_bez_subpaths(path: &BezPath) -> Vec<VectorPath> {
590    let mut output = Vec::new();
591    let mut current = BezPath::new();
592
593    for element in path.elements().iter().copied() {
594        if matches!(element, PathEl::MoveTo(_)) && !current.is_empty() {
595            let sub = VectorPath::from_bez_path(&current);
596            if sub.anchors.len() >= 2 {
597                output.push(sub);
598            }
599            current = BezPath::new();
600        }
601        current.push(element);
602    }
603
604    if !current.is_empty() {
605        let sub = VectorPath::from_bez_path(&current);
606        if sub.anchors.len() >= 2 {
607            output.push(sub);
608        }
609    }
610
611    output
612}
613
614/// Expand a stroke into filled outlines (one contour per disjoint piece).
615///
616/// Dashes (if any) are expanded first; the resulting dash subpaths are then
617/// stroked with the given cap/join configuration.
618pub fn stroke_to_paths(
619    path: &VectorPath,
620    width: f64,
621    cap: kurbo::Cap,
622    join: kurbo::Join,
623    miter_limit: f64,
624    dash: Option<(&[f64], f64)>,
625    tolerance: f64,
626) -> Result<Vec<VectorPath>, PathOpError> {
627    if !width.is_finite() || width <= 0.0 {
628        return Err(PathOpError::Empty);
629    }
630
631    let original = path.to_bez_path();
632
633    let source = match dash {
634        Some((pattern, offset)) => dash_bez_path(&original, pattern, offset).unwrap_or(original),
635        None => original,
636    };
637
638    let stroke = kurbo::Stroke::new(width)
639        .with_start_cap(cap)
640        .with_end_cap(cap)
641        .with_join(join)
642        .with_miter_limit(miter_limit);
643
644    let outline = kurbo::stroke(
645        source.elements().iter().copied(),
646        &stroke,
647        &kurbo::StrokeOpts::default(),
648        tolerance.max(1e-4),
649    );
650
651    let result = split_bez_subpaths(&outline);
652
653    if result.is_empty() {
654        Err(PathOpError::Empty)
655    } else {
656        Ok(result)
657    }
658}
659
660/// Fit a simpler path through the same geometry within `tolerance` document
661/// units (kurbo curve fitting).
662pub fn simplify_path(path: &VectorPath, tolerance: f64) -> VectorPath {
663    let simplified = kurbo::simplify::simplify_bezpath(
664        path.to_bez_path(),
665        tolerance.max(1e-4),
666        &kurbo::simplify::SimplifyOptions::default(),
667    );
668
669    VectorPath::from_bez_path(&simplified)
670}
671
672/// Offset a path by `amount` in document units.
673///
674/// Positive amount expands closed contours outward based on their winding.
675/// Negative amount insets closed contours. Open contours are shifted to their
676/// left side for positive amount.
677///
678/// This is a deterministic flattened-polyline offset. Exact cubic offset curves
679/// are not generally cubic Béziers (v1).
680pub fn offset_bez_path(path: &BezPath, amount: f64, tolerance: f64) -> Option<BezPath> {
681    if !amount.is_finite() {
682        return None;
683    }
684
685    if amount.abs() <= 1e-9 {
686        return Some(path.clone());
687    }
688
689    let contours = flatten_to_contours(path, tolerance.max(0.01));
690    if contours.is_empty() {
691        return None;
692    }
693
694    let mut out = BezPath::new();
695
696    for contour in contours {
697        let Some(offset) = offset_contour(&contour.points, contour.closed, amount) else {
698            continue;
699        };
700
701        if offset.len() < 2 {
702            continue;
703        }
704
705        out.move_to(pt(offset[0]));
706
707        for p in offset.iter().skip(1) {
708            out.line_to(pt(*p));
709        }
710
711        if contour.closed {
712            out.close_path();
713        }
714    }
715
716    if out.elements().is_empty() {
717        None
718    } else {
719        Some(out)
720    }
721}
722
723#[derive(Clone, Debug)]
724struct FlatContour {
725    points: Vec<DVec2>,
726    closed: bool,
727}
728
729fn flatten_to_contours(path: &BezPath, tolerance: f64) -> Vec<FlatContour> {
730    use kurbo::{ParamCurve, ParamCurveArclen};
731
732    let mut contours = Vec::new();
733    let mut current: Vec<DVec2> = Vec::new();
734    let mut cursor = DVec2::ZERO;
735    let mut start = DVec2::ZERO;
736
737    let flush = |contours: &mut Vec<FlatContour>, current: &mut Vec<DVec2>, closed: bool| {
738        dedupe_points(current);
739
740        if current.len() >= 2 {
741            contours.push(FlatContour {
742                points: std::mem::take(current),
743                closed,
744            });
745        } else {
746            current.clear();
747        }
748    };
749
750    for element in path.elements() {
751        match *element {
752            PathEl::MoveTo(p) => {
753                flush(&mut contours, &mut current, false);
754                cursor = DVec2::new(p.x, p.y);
755                start = cursor;
756                current.push(cursor);
757            }
758
759            PathEl::LineTo(p) => {
760                cursor = DVec2::new(p.x, p.y);
761                current.push(cursor);
762            }
763
764            PathEl::QuadTo(c, p) => {
765                let seg = kurbo::QuadBez::new(pt(cursor), c, p);
766
767                let len = seg.arclen(tolerance);
768                let steps = (len / tolerance).ceil().max(2.0) as usize;
769
770                for i in 1..=steps {
771                    let t = i as f64 / steps as f64;
772                    let q = seg.eval(t);
773                    current.push(DVec2::new(q.x, q.y));
774                }
775
776                cursor = DVec2::new(p.x, p.y);
777            }
778
779            PathEl::CurveTo(c1, c2, p) => {
780                let seg = CubicBez::new(pt(cursor), c1, c2, p);
781
782                let len = seg.arclen(tolerance);
783                let steps = (len / tolerance).ceil().max(3.0) as usize;
784
785                for i in 1..=steps {
786                    let t = i as f64 / steps as f64;
787                    let q = seg.eval(t);
788                    current.push(DVec2::new(q.x, q.y));
789                }
790
791                cursor = DVec2::new(p.x, p.y);
792            }
793
794            PathEl::ClosePath => {
795                if (cursor - start).length_squared() > 1e-12 {
796                    current.push(start);
797                }
798
799                // Remove duplicated close point. We use ClosePath instead.
800                if current.len() >= 2
801                    && (current[0] - *current.last().unwrap()).length_squared() <= 1e-12
802                {
803                    current.pop();
804                }
805
806                flush(&mut contours, &mut current, true);
807                cursor = start;
808            }
809        }
810    }
811
812    flush(&mut contours, &mut current, false);
813
814    contours
815}
816
817fn dedupe_points(points: &mut Vec<DVec2>) {
818    let mut out = Vec::with_capacity(points.len());
819
820    for p in points.drain(..) {
821        if out
822            .last()
823            .map(|last: &DVec2| (*last - p).length_squared() > 1e-12)
824            .unwrap_or(true)
825        {
826            out.push(p);
827        }
828    }
829
830    *points = out;
831}
832
833fn offset_contour(points: &[DVec2], closed: bool, amount: f64) -> Option<Vec<DVec2>> {
834    if points.len() < 2 {
835        return None;
836    }
837
838    if closed && points.len() < 3 {
839        return None;
840    }
841
842    if closed {
843        offset_closed_contour(points, amount)
844    } else {
845        offset_open_contour(points, amount)
846    }
847}
848
849fn offset_open_contour(points: &[DVec2], amount: f64) -> Option<Vec<DVec2>> {
850    let n = points.len();
851
852    let mut out = Vec::with_capacity(n);
853
854    for i in 0..n {
855        if i == 0 {
856            let dir = unit(points[1] - points[0])?;
857            out.push(points[0] + left_normal(dir) * amount);
858        } else if i == n - 1 {
859            let dir = unit(points[n - 1] - points[n - 2])?;
860            out.push(points[n - 1] + left_normal(dir) * amount);
861        } else {
862            let prev = unit(points[i] - points[i - 1])?;
863            let next = unit(points[i + 1] - points[i])?;
864            let n0 = left_normal(prev);
865            let n1 = left_normal(next);
866            out.push(join_point(points[i], prev, next, n0, n1, amount));
867        }
868    }
869
870    Some(out)
871}
872
873fn offset_closed_contour(points: &[DVec2], amount: f64) -> Option<Vec<DVec2>> {
874    let n = points.len();
875    let area = signed_area(points);
876
877    // For a positive shoelace winding, the contour interior is on the left
878    // side of edges, so outward is the right normal. For negative winding,
879    // outward is the left normal.
880    let outward_right = area >= 0.0;
881
882    let mut out = Vec::with_capacity(n);
883
884    for i in 0..n {
885        let prev_i = (i + n - 1) % n;
886        let next_i = (i + 1) % n;
887
888        let prev_dir = unit(points[i] - points[prev_i])?;
889        let next_dir = unit(points[next_i] - points[i])?;
890
891        let n0 = if outward_right {
892            right_normal(prev_dir)
893        } else {
894            left_normal(prev_dir)
895        };
896
897        let n1 = if outward_right {
898            right_normal(next_dir)
899        } else {
900            left_normal(next_dir)
901        };
902
903        out.push(join_point(points[i], prev_dir, next_dir, n0, n1, amount));
904    }
905
906    Some(out)
907}
908
909fn signed_area(points: &[DVec2]) -> f64 {
910    let mut area = 0.0;
911
912    for i in 0..points.len() {
913        let a = points[i];
914        let b = points[(i + 1) % points.len()];
915        area += a.x * b.y - b.x * a.y;
916    }
917
918    area * 0.5
919}
920
921fn unit(v: DVec2) -> Option<DVec2> {
922    let len = v.length();
923
924    if len <= 1e-12 || !len.is_finite() {
925        None
926    } else {
927        Some(v / len)
928    }
929}
930
931fn left_normal(v: DVec2) -> DVec2 {
932    DVec2::new(-v.y, v.x)
933}
934
935fn right_normal(v: DVec2) -> DVec2 {
936    DVec2::new(v.y, -v.x)
937}
938
939fn join_point(
940    p: DVec2,
941    prev_dir: DVec2,
942    next_dir: DVec2,
943    prev_normal: DVec2,
944    next_normal: DVec2,
945    amount: f64,
946) -> DVec2 {
947    let a0 = p + prev_normal * amount;
948    let a1 = p + next_normal * amount;
949
950    match line_intersection(a0, prev_dir, a1, next_dir) {
951        Some(miter) => {
952            let miter_len = (miter - p).length();
953            let limit = amount.abs() * 8.0 + 1e-6;
954
955            if miter_len.is_finite() && miter_len <= limit {
956                miter
957            } else {
958                // Bevel-ish fallback: average the two offset endpoints.
959                (a0 + a1) * 0.5
960            }
961        }
962
963        None => (a0 + a1) * 0.5,
964    }
965}
966
967fn line_intersection(p: DVec2, r: DVec2, q: DVec2, s: DVec2) -> Option<DVec2> {
968    let cross = r.x * s.y - r.y * s.x;
969
970    if cross.abs() <= 1e-12 {
971        return None;
972    }
973
974    let qp = q - p;
975    let t = (qp.x * s.y - qp.y * s.x) / cross;
976
977    Some(p + r * t)
978}