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 { index: usize, pos: DVec2 },
116    SetTanIn { index: usize, tan: DVec2 },
117    SetTanOut { index: usize, tan: DVec2 },
118    SetMode { index: usize, mode: TangentMode },
119    Delete { index: usize },
120    Insert { index: usize, anchor: Anchor },
121    SetClosed { closed: bool },
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum PathHit {
126    OnPath,
127    Inside,
128}
129
130#[derive(Clone, Copy, Debug, thiserror::Error)]
131pub enum GeometryError {
132    #[error("segment index {0} out of range")]
133    SegmentOutOfRange(usize),
134    #[error("anchor index {0} out of range")]
135    AnchorOutOfRange(usize),
136}
137
138fn pt(v: DVec2) -> Point {
139    Point::new(v.x, v.y)
140}
141
142impl VectorPath {
143    pub fn segment_count(&self) -> usize {
144        let n = self.anchors.len();
145        if self.closed { n } else { n.saturating_sub(1) }
146    }
147
148    pub fn to_bez_path(&self) -> BezPath {
149        let mut p = BezPath::new();
150        let n = self.anchors.len();
151        if n == 0 {
152            return p;
153        }
154        p.move_to(pt(self.anchors[0].pos));
155        for i in 0..self.segment_count() {
156            let a = &self.anchors[i];
157            let b = &self.anchors[(i + 1) % n];
158            p.curve_to(pt(a.pos + a.tan_out), pt(b.pos + b.tan_in), pt(b.pos));
159        }
160        if self.closed {
161            p.close_path();
162        }
163        p
164    }
165
166    pub fn from_bez_path(path: &BezPath) -> Self {
167        let mut out = VectorPath::default();
168        let mut start = DVec2::ZERO;
169        for el in path.elements() {
170            match *el {
171                PathEl::MoveTo(p) => {
172                    let v = DVec2::new(p.x, p.y);
173                    start = v;
174                    out.anchors.push(Anchor::corner(v));
175                }
176                PathEl::LineTo(p) => out.anchors.push(Anchor::corner(DVec2::new(p.x, p.y))),
177                PathEl::QuadTo(q1, q2) => {
178                    // elevate quad to cubic
179                    let prev = out.anchors.last().map(|a| a.pos).unwrap_or_default();
180                    let q1 = DVec2::new(q1.x, q1.y);
181                    let end = DVec2::new(q2.x, q2.y);
182                    let c1 = prev + (q1 - prev) * (2.0 / 3.0);
183                    let c2 = end + (q1 - end) * (2.0 / 3.0);
184                    if let Some(last) = out.anchors.last_mut() {
185                        last.tan_out = c1 - last.pos;
186                    }
187                    let mut a = Anchor::corner(end);
188                    a.tan_in = c2 - end;
189                    out.anchors.push(a);
190                }
191                PathEl::CurveTo(c1, c2, p) => {
192                    let (c1, c2, end) = (
193                        DVec2::new(c1.x, c1.y),
194                        DVec2::new(c2.x, c2.y),
195                        DVec2::new(p.x, p.y),
196                    );
197                    if let Some(last) = out.anchors.last_mut() {
198                        last.tan_out = c1 - last.pos;
199                    }
200                    let mut a = Anchor::corner(end);
201                    a.tan_in = c2 - end;
202                    out.anchors.push(a);
203                }
204                PathEl::ClosePath => {
205                    out.closed = true;
206                    // merge duplicated endpoint back into the first anchor
207                    if out.anchors.len() >= 2 {
208                        let last = *out.anchors.last().unwrap();
209                        if (last.pos - start).length_squared() < 1e-12 {
210                            out.anchors[0].tan_in = last.tan_in;
211                            out.anchors.pop();
212                        }
213                    }
214                }
215            }
216        }
217        for a in &mut out.anchors {
218            a.mode = detect_mode(a.tan_in, a.tan_out);
219        }
220        out
221    }
222
223    /// Return `(segment_index, t_param, distance)` for the nearest cubic segment.
224    pub fn nearest_segment(&self, point: DVec2) -> Option<(usize, f64, f64)> {
225        if self.anchors.len() < 2 {
226            return None;
227        }
228
229        let q = pt(point);
230        let n = self.anchors.len();
231        let seg_count = self.segment_count();
232
233        let mut best_seg = 0usize;
234        let mut best_t = 0.0;
235        let mut best_dist = f64::MAX;
236
237        for i in 0..seg_count {
238            let a = &self.anchors[i];
239            let b = &self.anchors[(i + 1) % n];
240            let cubic = CubicBez::new(
241                pt(a.pos),
242                pt(a.pos + a.tan_out),
243                pt(b.pos + b.tan_in),
244                pt(b.pos),
245            );
246            let hit = cubic.nearest(q, 1e-6);
247            let dist = hit.distance_sq.sqrt();
248            if dist < best_dist {
249                best_seg = i;
250                best_t = hit.t;
251                best_dist = dist;
252            }
253        }
254
255        Some((best_seg, best_t, best_dist))
256    }
257
258    pub fn hit_test(&self, p: DVec2, tol: f64) -> Option<PathHit> {
259        let path = self.to_bez_path();
260        let q = pt(p);
261        let mut best_sq = f64::MAX;
262        for seg in path.segments() {
263            best_sq = best_sq.min(seg.nearest(q, 1e-6).distance_sq);
264        }
265        if best_sq.sqrt() <= tol {
266            return Some(PathHit::OnPath);
267        }
268        if self.closed && path.contains(q) {
269            return Some(PathHit::Inside);
270        }
271        None
272    }
273
274    /// De Casteljau split of segment `seg` at parameter `t`. New anchor is Smooth.
275    pub fn insert_anchor_at(&mut self, seg: usize, t: f64) -> Result<(), GeometryError> {
276        if seg >= self.segment_count() {
277            return Err(GeometryError::SegmentOutOfRange(seg));
278        }
279        let n = self.anchors.len();
280        let (i, j) = (seg, (seg + 1) % n);
281        let a = self.anchors[i];
282        let b = self.anchors[j];
283        let (p0, p1, p2, p3) = (a.pos, a.pos + a.tan_out, b.pos + b.tan_in, b.pos);
284        let q0 = p0.lerp(p1, t);
285        let q1 = p1.lerp(p2, t);
286        let q2 = p2.lerp(p3, t);
287        let r0 = q0.lerp(q1, t);
288        let r1 = q1.lerp(q2, t);
289        let s = r0.lerp(r1, t);
290        self.anchors[i].tan_out = q0 - p0;
291        self.anchors[j].tan_in = q2 - p3;
292        self.anchors.insert(
293            i + 1,
294            Anchor {
295                pos: s,
296                tan_in: r0 - s,
297                tan_out: r1 - s,
298                mode: TangentMode::Smooth,
299            },
300        );
301        Ok(())
302    }
303
304    /// Round every Corner anchor by pulling back `radius` along both adjacent
305    /// edges and joining with a smooth curve (quarter-circle-ish cubic
306    /// approximation, k=0.5523 scaled by the pullback distance).
307    pub fn round_corners(&self, radius: f64) -> VectorPath {
308        if radius <= 1e-9 || self.anchors.len() < 3 {
309            return self.clone();
310        }
311        let n = self.anchors.len();
312        let seg_count = if self.closed { n } else { n.saturating_sub(1) };
313        if seg_count < 2 {
314            return self.clone();
315        }
316
317        let mut out = Vec::with_capacity(n * 2);
318        for i in 0..n {
319            let a = self.anchors[i];
320            if a.mode != TangentMode::Corner {
321                out.push(a);
322                continue;
323            }
324            // Skip endpoints of an open path - nothing to round into.
325            let has_prev = self.closed || i > 0;
326            let has_next = self.closed || i + 1 < n;
327            if !has_prev || !has_next {
328                out.push(a);
329                continue;
330            }
331            let prev = self.anchors[(i + n - 1) % n];
332            let next = self.anchors[(i + 1) % n];
333
334            let to_prev = prev.pos - a.pos;
335            let to_next = next.pos - a.pos;
336            let (len_prev, len_next) = (to_prev.length(), to_next.length());
337            if len_prev < 1e-9 || len_next < 1e-9 {
338                out.push(a);
339                continue;
340            }
341            // Cap pullback at 45% of the shorter adjacent edge so two rounded
342            // corners on a short edge can't cross each other.
343            let r = radius.min(len_prev * 0.45).min(len_next * 0.45);
344            let dir_prev = to_prev / len_prev;
345            let dir_next = to_next / len_next;
346
347            let p_in = a.pos + dir_prev * r; // pullback toward prev
348            let p_out = a.pos + dir_next * r; // pullback toward next
349
350            // Cubic handle length for a circular-ish arc (standard
351            // 4/3*tan(θ/4) ≈ 0.5523 for a quarter turn).
352            const K: f64 = 0.5523;
353            out.push(Anchor {
354                pos: p_in,
355                tan_in: DVec2::ZERO, // outer side of the corner stays sharp
356                tan_out: -dir_prev * (r * K),
357                mode: TangentMode::Smooth,
358            });
359            out.push(Anchor {
360                pos: p_out,
361                tan_in: -dir_next * (r * K),
362                tan_out: DVec2::ZERO,
363                mode: TangentMode::Smooth,
364            });
365        }
366
367        VectorPath {
368            anchors: out,
369            closed: self.closed,
370        }
371    }
372
373    /// Reverse direction (Trim Path needs this). Swaps in/out tangents.
374    pub fn reverse(&mut self) {
375        self.anchors.reverse();
376        for a in &mut self.anchors {
377            std::mem::swap(&mut a.tan_in, &mut a.tan_out);
378        }
379    }
380
381    /// Apply an edit and return its exact inverse (None if out of range).
382    pub fn apply_edit(&mut self, edit: &AnchorEdit) -> Option<AnchorEdit> {
383        use AnchorEdit::*;
384        match edit {
385            SetPos { index, pos } => {
386                let a = self.anchors.get_mut(*index)?;
387                let inv = SetPos {
388                    index: *index,
389                    pos: a.pos,
390                };
391                a.pos = *pos;
392                Some(inv)
393            }
394            SetTanIn { index, tan } => {
395                let a = self.anchors.get_mut(*index)?;
396                let inv = SetTanIn {
397                    index: *index,
398                    tan: a.tan_in,
399                };
400                a.tan_in = *tan;
401                if a.mode == TangentMode::Symmetric {
402                    a.tan_out = -*tan;
403                }
404                Some(inv)
405            }
406            SetTanOut { index, tan } => {
407                let a = self.anchors.get_mut(*index)?;
408                let inv = SetTanOut {
409                    index: *index,
410                    tan: a.tan_out,
411                };
412                a.tan_out = *tan;
413                if a.mode == TangentMode::Symmetric {
414                    a.tan_in = -*tan;
415                }
416                Some(inv)
417            }
418            SetMode { index, mode } => {
419                let a = self.anchors.get_mut(*index)?;
420                let inv = SetMode {
421                    index: *index,
422                    mode: a.mode,
423                };
424                a.mode = *mode;
425                // Corner->Smooth synthesizes tangents if zero (Glaxnimate 0.6).
426                if *mode != TangentMode::Corner
427                    && a.tan_in.length_squared() < 1e-12
428                    && a.tan_out.length_squared() < 1e-12
429                {
430                    a.tan_out = DVec2::new(10.0, 0.0);
431                    a.tan_in = -a.tan_out;
432                }
433                Some(inv)
434            }
435            Delete { index } => {
436                if *index >= self.anchors.len() {
437                    return None;
438                }
439                let a = self.anchors.remove(*index);
440                Some(Insert {
441                    index: *index,
442                    anchor: a,
443                })
444            }
445            Insert { index, anchor } => {
446                if *index > self.anchors.len() {
447                    return None;
448                }
449                self.anchors.insert(*index, *anchor);
450                Some(Delete { index: *index })
451            }
452            SetClosed { closed } => {
453                let inv = SetClosed {
454                    closed: self.closed,
455                };
456                self.closed = *closed;
457                Some(inv)
458            }
459        }
460    }
461}
462
463fn detect_mode(tin: DVec2, tout: DVec2) -> TangentMode {
464    let (li, lo) = (tin.length(), tout.length());
465    if li < 1e-9 || lo < 1e-9 {
466        return TangentMode::Corner;
467    }
468    let cross = tin.x * tout.y - tin.y * tout.x;
469    let colinear_opposed = cross.abs() <= 1e-6 * li * lo && tin.dot(tout) < 0.0;
470    if !colinear_opposed {
471        TangentMode::Corner
472    } else if (li - lo).abs() < 1e-6 {
473        TangentMode::Symmetric
474    } else {
475        TangentMode::Smooth
476    }
477}
478
479/// Errors from boolean operations and stroke expansion.
480#[derive(Debug, thiserror::Error)]
481pub enum PathOpError {
482    #[error("path operation requires closed contours")]
483    OpenPath,
484    #[error("path operation produced no geometry")]
485    Empty,
486    #[error("boolean operation failed: {0}")]
487    Boolean(#[from] linesweeper::Error),
488}
489
490fn map_boolean_op(op: BooleanOp) -> linesweeper::BinaryOp {
491    match op {
492        BooleanOp::Union => linesweeper::BinaryOp::Union,
493        BooleanOp::Intersection => linesweeper::BinaryOp::Intersection,
494        BooleanOp::Difference => linesweeper::BinaryOp::Difference,
495        BooleanOp::Xor => linesweeper::BinaryOp::Xor,
496    }
497}
498
499/// Concatenate closed `contours` into one multi-subpath `BezPath`, suitable
500/// as an input to [`boolean_bez`].
501pub fn contours_to_bez(contours: &[VectorPath]) -> BezPath {
502    let mut out = BezPath::new();
503    for contour in contours {
504        out.extend(contour.to_bez_path().elements().iter().copied());
505    }
506    out
507}
508
509/// Linesweeper-backed boolean op on raw Bézier outlines.
510///
511/// Unlike [`boolean_op`], both sides may already be compound (multi-subpath)
512/// outlines, so folding N selected shapes never discards intermediate holes or
513/// disjoint pieces.
514pub fn boolean_bez(
515    a: &BezPath,
516    b: &BezPath,
517    op: BooleanOp,
518) -> Result<Vec<VectorPath>, PathOpError> {
519    let contours =
520        linesweeper::binary_op(a, b, linesweeper::FillRule::NonZero, map_boolean_op(op))?;
521
522    Ok(contours
523        .contours()
524        .filter_map(|contour| {
525            let path = VectorPath::from_bez_path(&contour.path);
526            (path.closed && path.anchors.len() >= 3).then_some(path)
527        })
528        .collect())
529}
530
531/// Boolean op between two single-contour paths. Multi-contour inputs should
532/// use [`boolean_bez`] via [`contours_to_bez`] so holes survive the fold.
533pub fn boolean_op(
534    a: &VectorPath,
535    b: &VectorPath,
536    op: BooleanOp,
537) -> Result<Vec<VectorPath>, PathOpError> {
538    if !a.closed || !b.closed {
539        return Err(PathOpError::OpenPath);
540    }
541    boolean_bez(&a.to_bez_path(), &b.to_bez_path(), op)
542}
543
544/// Split a multi-subpath `BezPath` into one [`VectorPath`] per subpath
545/// (`MoveTo` .. next `MoveTo`). Subpaths with fewer than two anchors are
546/// dropped; open subpaths stay open.
547pub fn split_bez_subpaths(path: &BezPath) -> Vec<VectorPath> {
548    let mut output = Vec::new();
549    let mut current = BezPath::new();
550
551    for element in path.elements().iter().copied() {
552        if matches!(element, PathEl::MoveTo(_)) && !current.is_empty() {
553            let sub = VectorPath::from_bez_path(&current);
554            if sub.anchors.len() >= 2 {
555                output.push(sub);
556            }
557            current = BezPath::new();
558        }
559        current.push(element);
560    }
561
562    if !current.is_empty() {
563        let sub = VectorPath::from_bez_path(&current);
564        if sub.anchors.len() >= 2 {
565            output.push(sub);
566        }
567    }
568
569    output
570}
571
572/// Expand a stroke into filled outlines (one contour per disjoint piece).
573///
574/// Dashes (if any) are expanded first; the resulting dash subpaths are then
575/// stroked with the given cap/join configuration.
576pub fn stroke_to_paths(
577    path: &VectorPath,
578    width: f64,
579    cap: kurbo::Cap,
580    join: kurbo::Join,
581    miter_limit: f64,
582    dash: Option<(&[f64], f64)>,
583    tolerance: f64,
584) -> Result<Vec<VectorPath>, PathOpError> {
585    if !width.is_finite() || width <= 0.0 {
586        return Err(PathOpError::Empty);
587    }
588
589    let original = path.to_bez_path();
590
591    let source = match dash {
592        Some((pattern, offset)) => dash_bez_path(&original, pattern, offset).unwrap_or(original),
593        None => original,
594    };
595
596    let stroke = kurbo::Stroke::new(width)
597        .with_start_cap(cap)
598        .with_end_cap(cap)
599        .with_join(join)
600        .with_miter_limit(miter_limit);
601
602    let outline = kurbo::stroke(
603        source.elements().iter().copied(),
604        &stroke,
605        &kurbo::StrokeOpts::default(),
606        tolerance.max(1e-4),
607    );
608
609    let result = split_bez_subpaths(&outline);
610
611    if result.is_empty() {
612        Err(PathOpError::Empty)
613    } else {
614        Ok(result)
615    }
616}
617
618/// Fit a simpler path through the same geometry within `tolerance` document
619/// units (kurbo curve fitting).
620pub fn simplify_path(path: &VectorPath, tolerance: f64) -> VectorPath {
621    let simplified = kurbo::simplify::simplify_bezpath(
622        path.to_bez_path(),
623        tolerance.max(1e-4),
624        &kurbo::simplify::SimplifyOptions::default(),
625    );
626
627    VectorPath::from_bez_path(&simplified)
628}
629
630/// Offset a path by `amount` in document units.
631///
632/// Positive amount expands closed contours outward based on their winding.
633/// Negative amount insets closed contours. Open contours are shifted to their
634/// left side for positive amount.
635///
636/// This is a deterministic flattened-polyline offset. Exact cubic offset curves
637/// are not generally cubic Béziers (v1).
638pub fn offset_bez_path(path: &BezPath, amount: f64, tolerance: f64) -> Option<BezPath> {
639    if !amount.is_finite() {
640        return None;
641    }
642
643    if amount.abs() <= 1e-9 {
644        return Some(path.clone());
645    }
646
647    let contours = flatten_to_contours(path, tolerance.max(0.01));
648    if contours.is_empty() {
649        return None;
650    }
651
652    let mut out = BezPath::new();
653
654    for contour in contours {
655        let offset = offset_contour(&contour.points, contour.closed, amount)?;
656
657        if offset.len() < 2 {
658            continue;
659        }
660
661        out.move_to(pt(offset[0]));
662
663        for p in offset.iter().skip(1) {
664            out.line_to(pt(*p));
665        }
666
667        if contour.closed {
668            out.close_path();
669        }
670    }
671
672    if out.elements().is_empty() {
673        None
674    } else {
675        Some(out)
676    }
677}
678
679#[derive(Clone, Debug)]
680struct FlatContour {
681    points: Vec<DVec2>,
682    closed: bool,
683}
684
685fn flatten_to_contours(path: &BezPath, tolerance: f64) -> Vec<FlatContour> {
686    use kurbo::{ParamCurve, ParamCurveArclen};
687
688    let mut contours = Vec::new();
689    let mut current: Vec<DVec2> = Vec::new();
690    let mut cursor = DVec2::ZERO;
691    let mut start = DVec2::ZERO;
692
693    let flush = |contours: &mut Vec<FlatContour>, current: &mut Vec<DVec2>, closed: bool| {
694        dedupe_points(current);
695
696        if current.len() >= 2 {
697            contours.push(FlatContour {
698                points: std::mem::take(current),
699                closed,
700            });
701        } else {
702            current.clear();
703        }
704    };
705
706    for element in path.elements() {
707        match *element {
708            PathEl::MoveTo(p) => {
709                flush(&mut contours, &mut current, false);
710                cursor = DVec2::new(p.x, p.y);
711                start = cursor;
712                current.push(cursor);
713            }
714
715            PathEl::LineTo(p) => {
716                cursor = DVec2::new(p.x, p.y);
717                current.push(cursor);
718            }
719
720            PathEl::QuadTo(c, p) => {
721                let seg = kurbo::QuadBez::new(pt(cursor), c, p);
722
723                let len = seg.arclen(tolerance);
724                let steps = (len / tolerance).ceil().max(2.0) as usize;
725
726                for i in 1..=steps {
727                    let t = i as f64 / steps as f64;
728                    let q = seg.eval(t);
729                    current.push(DVec2::new(q.x, q.y));
730                }
731
732                cursor = DVec2::new(p.x, p.y);
733            }
734
735            PathEl::CurveTo(c1, c2, p) => {
736                let seg = CubicBez::new(pt(cursor), c1, c2, p);
737
738                let len = seg.arclen(tolerance);
739                let steps = (len / tolerance).ceil().max(3.0) as usize;
740
741                for i in 1..=steps {
742                    let t = i as f64 / steps as f64;
743                    let q = seg.eval(t);
744                    current.push(DVec2::new(q.x, q.y));
745                }
746
747                cursor = DVec2::new(p.x, p.y);
748            }
749
750            PathEl::ClosePath => {
751                if (cursor - start).length_squared() > 1e-12 {
752                    current.push(start);
753                }
754
755                // Remove duplicated close point. We use ClosePath instead.
756                if current.len() >= 2
757                    && (current[0] - *current.last().unwrap()).length_squared() <= 1e-12
758                {
759                    current.pop();
760                }
761
762                flush(&mut contours, &mut current, true);
763                cursor = start;
764            }
765        }
766    }
767
768    flush(&mut contours, &mut current, false);
769
770    contours
771}
772
773fn dedupe_points(points: &mut Vec<DVec2>) {
774    let mut out = Vec::with_capacity(points.len());
775
776    for p in points.drain(..) {
777        if out
778            .last()
779            .map(|last: &DVec2| (*last - p).length_squared() > 1e-12)
780            .unwrap_or(true)
781        {
782            out.push(p);
783        }
784    }
785
786    *points = out;
787}
788
789fn offset_contour(points: &[DVec2], closed: bool, amount: f64) -> Option<Vec<DVec2>> {
790    if points.len() < 2 {
791        return None;
792    }
793
794    if closed && points.len() < 3 {
795        return None;
796    }
797
798    if closed {
799        offset_closed_contour(points, amount)
800    } else {
801        offset_open_contour(points, amount)
802    }
803}
804
805fn offset_open_contour(points: &[DVec2], amount: f64) -> Option<Vec<DVec2>> {
806    let n = points.len();
807
808    let mut out = Vec::with_capacity(n);
809
810    for i in 0..n {
811        if i == 0 {
812            let dir = unit(points[1] - points[0])?;
813            out.push(points[0] + left_normal(dir) * amount);
814        } else if i == n - 1 {
815            let dir = unit(points[n - 1] - points[n - 2])?;
816            out.push(points[n - 1] + left_normal(dir) * amount);
817        } else {
818            let prev = unit(points[i] - points[i - 1])?;
819            let next = unit(points[i + 1] - points[i])?;
820            let n0 = left_normal(prev);
821            let n1 = left_normal(next);
822            out.push(join_point(points[i], prev, next, n0, n1, amount));
823        }
824    }
825
826    Some(out)
827}
828
829fn offset_closed_contour(points: &[DVec2], amount: f64) -> Option<Vec<DVec2>> {
830    let n = points.len();
831    let area = signed_area(points);
832
833    // For a positive shoelace winding, the contour interior is on the left
834    // side of edges, so outward is the right normal. For negative winding,
835    // outward is the left normal.
836    let outward_right = area >= 0.0;
837
838    let mut out = Vec::with_capacity(n);
839
840    for i in 0..n {
841        let prev_i = (i + n - 1) % n;
842        let next_i = (i + 1) % n;
843
844        let prev_dir = unit(points[i] - points[prev_i])?;
845        let next_dir = unit(points[next_i] - points[i])?;
846
847        let n0 = if outward_right {
848            right_normal(prev_dir)
849        } else {
850            left_normal(prev_dir)
851        };
852
853        let n1 = if outward_right {
854            right_normal(next_dir)
855        } else {
856            left_normal(next_dir)
857        };
858
859        out.push(join_point(points[i], prev_dir, next_dir, n0, n1, amount));
860    }
861
862    Some(out)
863}
864
865fn signed_area(points: &[DVec2]) -> f64 {
866    let mut area = 0.0;
867
868    for i in 0..points.len() {
869        let a = points[i];
870        let b = points[(i + 1) % points.len()];
871        area += a.x * b.y - b.x * a.y;
872    }
873
874    area * 0.5
875}
876
877fn unit(v: DVec2) -> Option<DVec2> {
878    let len = v.length();
879
880    if len <= 1e-12 || !len.is_finite() {
881        None
882    } else {
883        Some(v / len)
884    }
885}
886
887fn left_normal(v: DVec2) -> DVec2 {
888    DVec2::new(-v.y, v.x)
889}
890
891fn right_normal(v: DVec2) -> DVec2 {
892    DVec2::new(v.y, -v.x)
893}
894
895fn join_point(
896    p: DVec2,
897    prev_dir: DVec2,
898    next_dir: DVec2,
899    prev_normal: DVec2,
900    next_normal: DVec2,
901    amount: f64,
902) -> DVec2 {
903    let a0 = p + prev_normal * amount;
904    let a1 = p + next_normal * amount;
905
906    match line_intersection(a0, prev_dir, a1, next_dir) {
907        Some(miter) => {
908            let miter_len = (miter - p).length();
909            let limit = amount.abs() * 8.0 + 1e-6;
910
911            if miter_len.is_finite() && miter_len <= limit {
912                miter
913            } else {
914                // Bevel-ish fallback: average the two offset endpoints.
915                (a0 + a1) * 0.5
916            }
917        }
918
919        None => (a0 + a1) * 0.5,
920    }
921}
922
923fn line_intersection(p: DVec2, r: DVec2, q: DVec2, s: DVec2) -> Option<DVec2> {
924    let cross = r.x * s.y - r.y * s.x;
925
926    if cross.abs() <= 1e-12 {
927        return None;
928    }
929
930    let qp = q - p;
931    let t = (qp.x * s.y - qp.y * s.x) / cross;
932
933    Some(p + r * t)
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    fn square() -> VectorPath {
941        VectorPath {
942            closed: true,
943            anchors: vec![
944                Anchor::corner(DVec2::new(0.0, 0.0)),
945                Anchor::corner(DVec2::new(10.0, 0.0)),
946                Anchor::corner(DVec2::new(10.0, 10.0)),
947                Anchor::corner(DVec2::new(0.0, 10.0)),
948            ],
949        }
950    }
951
952    #[test]
953    fn roundtrip_bez() {
954        let s = square();
955        let back = VectorPath::from_bez_path(&s.to_bez_path());
956        assert_eq!(back.anchors.len(), 4);
957        assert!(back.closed);
958    }
959
960    #[test]
961    fn hit_inside_and_edge() {
962        let s = square();
963        assert_eq!(s.hit_test(DVec2::new(5.0, 5.0), 0.5), Some(PathHit::Inside));
964        assert_eq!(
965            s.hit_test(DVec2::new(10.0, 5.0), 0.5),
966            Some(PathHit::OnPath)
967        );
968        assert_eq!(s.hit_test(DVec2::new(20.0, 20.0), 0.5), None);
969    }
970
971    #[test]
972    fn edit_inverse_roundtrip() {
973        let mut s = square();
974        let orig = s.clone();
975        let inv1 = s
976            .apply_edit(&AnchorEdit::SetPos {
977                index: 0,
978                pos: DVec2::new(-5.0, -5.0),
979            })
980            .unwrap();
981        let inv2 = s.apply_edit(&AnchorEdit::Delete { index: 2 }).unwrap();
982        s.apply_edit(&inv2).unwrap();
983        s.apply_edit(&inv1).unwrap();
984        assert_eq!(s, orig);
985    }
986
987    #[test]
988    fn insert_anchor_preserves_shape_endpoints() {
989        let mut s = square();
990        s.insert_anchor_at(0, 0.5).unwrap();
991        assert_eq!(s.anchors.len(), 5);
992        assert!((s.anchors[1].pos - DVec2::new(5.0, 0.0)).length() < 1e-9);
993    }
994
995    #[test]
996    fn nearest_segment_finds_closest_cubic() {
997        let s = square();
998        let (seg, t, dist) = s.nearest_segment(DVec2::new(5.0, -5.0)).unwrap();
999        assert_eq!(seg, 0); // top edge (y = 0)
1000        assert!((dist - 5.0).abs() < 1e-6);
1001        assert!(t > 0.3 && t < 0.7);
1002    }
1003
1004    #[test]
1005    fn nearest_segment_requires_two_anchors() {
1006        let mut s = VectorPath::default();
1007        assert!(s.nearest_segment(DVec2::ZERO).is_none());
1008        s.anchors.push(Anchor::corner(DVec2::ZERO));
1009        assert!(s.nearest_segment(DVec2::ZERO).is_none());
1010    }
1011}
1012
1013#[cfg(test)]
1014mod round_corner_tests {
1015    use super::*;
1016
1017    fn square() -> VectorPath {
1018        VectorPath {
1019            closed: true,
1020            anchors: vec![
1021                Anchor::corner(DVec2::new(0.0, 0.0)),
1022                Anchor::corner(DVec2::new(100.0, 0.0)),
1023                Anchor::corner(DVec2::new(100.0, 100.0)),
1024                Anchor::corner(DVec2::new(0.0, 100.0)),
1025            ],
1026        }
1027    }
1028
1029    #[test]
1030    fn zero_radius_is_identity() {
1031        let s = square();
1032        assert_eq!(s.round_corners(0.0), s);
1033    }
1034
1035    #[test]
1036    fn rounding_doubles_anchor_count_on_all_corners() {
1037        let s = square();
1038        let r = s.round_corners(10.0);
1039        assert_eq!(r.anchors.len(), 8);
1040        assert!(r.closed);
1041    }
1042
1043    #[test]
1044    fn pullback_points_lie_on_original_edges() {
1045        let s = square();
1046        let r = s.round_corners(10.0);
1047        for p in r.anchors.iter().map(|a| a.pos) {
1048            let on_edge = (p.x - 0.0).abs() < 1e-6
1049                || (p.x - 100.0).abs() < 1e-6
1050                || (p.y - 0.0).abs() < 1e-6
1051                || (p.y - 100.0).abs() < 1e-6;
1052            assert!(on_edge, "point {p:?} must lie on an original edge");
1053        }
1054    }
1055
1056    #[test]
1057    fn radius_clamped_on_tiny_shape() {
1058        let mut tiny = square();
1059        for a in &mut tiny.anchors {
1060            a.pos *= 0.1; // 10x10 square
1061        }
1062        let r = tiny.round_corners(100.0); // absurdly large radius
1063        for a in &r.anchors {
1064            assert!(a.pos.x >= -0.01 && a.pos.x <= 10.01);
1065            assert!(a.pos.y >= -0.01 && a.pos.y <= 10.01);
1066        }
1067    }
1068
1069    #[test]
1070    fn smooth_anchors_pass_through_unrounded() {
1071        let mut s = square();
1072        s.anchors[0].mode = TangentMode::Smooth;
1073        s.anchors[0].tan_in = DVec2::new(-5.0, 0.0);
1074        s.anchors[0].tan_out = DVec2::new(5.0, 0.0);
1075        let r = s.round_corners(10.0);
1076        // 1 unrounded (kept as-is) + 3 corners x2 = 7 anchors total.
1077        assert_eq!(r.anchors.len(), 7);
1078    }
1079
1080    #[test]
1081    fn open_path_does_not_round_endpoints() {
1082        let open = VectorPath {
1083            closed: false,
1084            anchors: vec![
1085                Anchor::corner(DVec2::new(0.0, 0.0)),
1086                Anchor::corner(DVec2::new(50.0, 0.0)),
1087                Anchor::corner(DVec2::new(50.0, 50.0)),
1088            ],
1089        };
1090        let r = open.round_corners(5.0);
1091        // Endpoint 0 and endpoint 2 (last) untouched; only middle corner rounds.
1092        assert_eq!(r.anchors.len(), 4); // 1 + 2 + 1
1093        assert_eq!(r.anchors[0].pos, DVec2::new(0.0, 0.0));
1094        assert_eq!(r.anchors.last().unwrap().pos, DVec2::new(50.0, 50.0));
1095    }
1096}
1097
1098#[cfg(test)]
1099mod dash_tests {
1100    use super::*;
1101    use kurbo::ParamCurveArclen;
1102
1103    fn line(length: f64) -> BezPath {
1104        let mut path = BezPath::new();
1105        path.move_to((0.0, 0.0));
1106        path.line_to((length, 0.0));
1107        path
1108    }
1109
1110    fn length(path: &BezPath) -> f64 {
1111        path.segments().map(|segment| segment.arclen(1e-6)).sum()
1112    }
1113
1114    #[test]
1115    fn dash_line_produces_expected_visible_length() {
1116        // 40 units with [10 on, 10 off] => 20 visible units.
1117        let dashed = dash_bez_path(&line(40.0), &[10.0, 10.0], 0.0).unwrap();
1118
1119        assert!((length(&dashed) - 20.0).abs() < 1e-5);
1120    }
1121
1122    #[test]
1123    fn dash_offset_shifts_pattern() {
1124        let a = dash_bez_path(&line(40.0), &[10.0, 10.0], 0.0).unwrap();
1125
1126        let b = dash_bez_path(&line(40.0), &[10.0, 10.0], 5.0).unwrap();
1127
1128        assert_ne!(a.elements(), b.elements());
1129    }
1130
1131    #[test]
1132    fn odd_pattern_matches_explicitly_doubled_pattern() {
1133        let path = line(100.0);
1134
1135        let odd = dash_bez_path(&path, &[10.0], 0.0).unwrap();
1136
1137        let doubled = dash_bez_path(&path, &[10.0, 10.0], 0.0).unwrap();
1138
1139        assert_eq!(odd.elements(), doubled.elements());
1140    }
1141
1142    #[test]
1143    fn negative_offset_is_supported() {
1144        let path = line(100.0);
1145
1146        let positive = dash_bez_path(&path, &[10.0, 5.0], 30.0).unwrap();
1147
1148        let negative = dash_bez_path(&path, &[10.0, 5.0], -30.0).unwrap();
1149
1150        assert!(positive.is_finite());
1151        assert!(negative.is_finite());
1152    }
1153
1154    #[test]
1155    fn rejects_invalid_patterns() {
1156        assert!(dash_bez_path(&line(10.0), &[], 0.0).is_none());
1157        assert!(dash_bez_path(&line(10.0), &[0.0, 0.0], 0.0).is_none());
1158        assert!(dash_bez_path(&line(10.0), &[-1.0, 2.0], 0.0).is_none());
1159        assert!(dash_bez_path(&line(10.0), &[f64::NAN, 2.0], 0.0).is_none());
1160        assert!(dash_bez_path(&line(10.0), &[1.0, 2.0], f64::NAN).is_none());
1161    }
1162}
1163
1164#[cfg(test)]
1165mod boolean_tests {
1166    use super::*;
1167
1168    fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorPath {
1169        let mut p = BezPath::new();
1170        p.move_to((x0, y0));
1171        p.line_to((x1, y0));
1172        p.line_to((x1, y1));
1173        p.line_to((x0, y1));
1174        p.close_path();
1175        VectorPath::from_bez_path(&p)
1176    }
1177
1178    #[test]
1179    fn boolean_difference_preserves_hole() {
1180        let outer = rect_path(0.0, 0.0, 100.0, 100.0);
1181        let inner = rect_path(25.0, 25.0, 75.0, 75.0);
1182
1183        let result = boolean_op(&outer, &inner, BooleanOp::Difference).unwrap();
1184
1185        assert_eq!(result.len(), 2); // outside boundary + hole
1186        assert!(result.iter().all(|p| p.closed));
1187    }
1188
1189    #[test]
1190    fn boolean_union_can_return_disjoint_contours() {
1191        let a = rect_path(0.0, 0.0, 10.0, 10.0);
1192        let b = rect_path(20.0, 0.0, 30.0, 10.0);
1193
1194        let result = boolean_op(&a, &b, BooleanOp::Union).unwrap();
1195
1196        assert_eq!(result.len(), 2);
1197    }
1198
1199    #[test]
1200    fn boolean_intersection_of_overlapping_squares_is_one_contour() {
1201        let a = rect_path(0.0, 0.0, 20.0, 20.0);
1202        let b = rect_path(10.0, 10.0, 30.0, 30.0);
1203
1204        let result = boolean_op(&a, &b, BooleanOp::Intersection).unwrap();
1205
1206        assert_eq!(result.len(), 1);
1207        let bb = contours_to_bez(&result).bounding_box();
1208        assert!((bb.x0 - 10.0).abs() < 1e-6 && (bb.x1 - 20.0).abs() < 1e-6);
1209    }
1210
1211    #[test]
1212    fn boolean_op_rejects_open_paths() {
1213        let mut open = rect_path(0.0, 0.0, 10.0, 10.0);
1214        open.closed = false;
1215        let closed = rect_path(0.0, 0.0, 5.0, 5.0);
1216
1217        assert!(matches!(
1218            boolean_op(&open, &closed, BooleanOp::Union),
1219            Err(PathOpError::OpenPath)
1220        ));
1221    }
1222
1223    #[test]
1224    fn boolean_intersection_of_disjoint_shapes_is_validly_empty() {
1225        let a = rect_path(0.0, 0.0, 10.0, 10.0);
1226        let b = rect_path(20.0, 0.0, 30.0, 10.0);
1227
1228        // Empty is a RESULT, not an error.
1229        let result = boolean_op(&a, &b, BooleanOp::Intersection).unwrap();
1230        assert!(result.is_empty());
1231
1232        // Difference under a covering cutter also legitimately erases.
1233        let covered = boolean_op(&a, &b, BooleanOp::Difference).is_ok();
1234        assert!(covered);
1235        let erased = rect_path(-5.0, -5.0, 15.0, 15.0);
1236        assert!(
1237            boolean_op(&a, &erased, BooleanOp::Difference)
1238                .unwrap()
1239                .is_empty()
1240        );
1241    }
1242
1243    #[test]
1244    fn boolean_bez_folds_compound_accumulator_without_losing_holes() {
1245        // Square with a hole (difference), unioned with an overlapping square.
1246        let outer = rect_path(0.0, 0.0, 100.0, 100.0);
1247        let inner = rect_path(25.0, 25.0, 75.0, 75.0);
1248        let holed = boolean_op(&outer, &inner, BooleanOp::Difference).unwrap();
1249
1250        let cutter = rect_path(60.0, 0.0, 160.0, 40.0);
1251
1252        // Fold via the BezPath-level wrapper: the hole must survive.
1253        let folded = boolean_bez(
1254            &contours_to_bez(&holed),
1255            &cutter.to_bez_path(),
1256            BooleanOp::Union,
1257        )
1258        .unwrap();
1259
1260        let all = contours_to_bez(&folded);
1261        let center = Point::new(50.0, 50.0);
1262        assert_eq!(all.winding(center), 0, "hole must remain after the fold");
1263    }
1264}
1265
1266#[cfg(test)]
1267mod stroke_tests {
1268    use super::*;
1269    use kurbo::ParamCurveArclen;
1270
1271    #[test]
1272    fn stroked_line_produces_closed_outline_near_expected_width() {
1273        let line = VectorPath {
1274            anchors: vec![
1275                Anchor::corner(DVec2::new(0.0, 0.0)),
1276                Anchor::corner(DVec2::new(100.0, 0.0)),
1277            ],
1278            closed: false,
1279        };
1280
1281        let outlines = stroke_to_paths(
1282            &line,
1283            4.0,
1284            kurbo::Cap::Butt,
1285            kurbo::Join::Miter,
1286            4.0,
1287            None,
1288            0.1,
1289        )
1290        .unwrap();
1291
1292        assert_eq!(outlines.len(), 1);
1293        assert!(outlines[0].closed);
1294        let bez = outlines[0].to_bez_path();
1295        let bb = bez.bounding_box();
1296        assert!((bb.height() - 4.0).abs() < 0.2, "height = {}", bb.height());
1297        assert!((bb.width() - 100.0).abs() < 0.2, "width = {}", bb.width());
1298    }
1299
1300    #[test]
1301    fn dashed_stroke_expands_each_dash() {
1302        let line = VectorPath {
1303            anchors: vec![
1304                Anchor::corner(DVec2::new(0.0, 0.0)),
1305                Anchor::corner(DVec2::new(100.0, 0.0)),
1306            ],
1307            closed: false,
1308        };
1309
1310        let outlines = stroke_to_paths(
1311            &line,
1312            2.0,
1313            kurbo::Cap::Butt,
1314            kurbo::Join::Bevel,
1315            4.0,
1316            Some(([10.0, 10.0].as_slice(), 0.0)),
1317            0.1,
1318        )
1319        .unwrap();
1320
1321        // 100 units of [10 on / 10 off] => 5 dashes => 5 outline pieces.
1322        assert_eq!(outlines.len(), 5);
1323        let total: f64 = outlines
1324            .iter()
1325            .map(|p| {
1326                p.to_bez_path()
1327                    .segments()
1328                    .map(|s| s.arclen(1e-3))
1329                    .sum::<f64>()
1330            })
1331            .sum();
1332        assert!(total > 0.0);
1333    }
1334
1335    #[test]
1336    fn closed_square_stroke_is_one_ring() {
1337        let square = VectorPath {
1338            anchors: vec![
1339                Anchor::corner(DVec2::new(0.0, 0.0)),
1340                Anchor::corner(DVec2::new(10.0, 0.0)),
1341                Anchor::corner(DVec2::new(10.0, 10.0)),
1342                Anchor::corner(DVec2::new(0.0, 10.0)),
1343            ],
1344            closed: true,
1345        };
1346
1347        let outlines = stroke_to_paths(
1348            &square,
1349            2.0,
1350            kurbo::Cap::Butt,
1351            kurbo::Join::Miter,
1352            4.0,
1353            None,
1354            0.1,
1355        )
1356        .unwrap();
1357
1358        // A closed shape's stroke is an annulus: outer ring + hole.
1359        assert_eq!(outlines.len(), 2);
1360        assert!(outlines.iter().all(|p| p.closed));
1361        let all = contours_to_bez(&outlines);
1362        assert_eq!(all.winding(Point::new(5.0, 5.0)), 0, "center stays hollow");
1363    }
1364
1365    #[test]
1366    fn invalid_width_is_an_error() {
1367        let line = VectorPath::default();
1368        assert!(
1369            stroke_to_paths(
1370                &line,
1371                0.0,
1372                kurbo::Cap::Butt,
1373                kurbo::Join::Miter,
1374                4.0,
1375                None,
1376                0.1
1377            )
1378            .is_err()
1379        );
1380        assert!(
1381            stroke_to_paths(
1382                &line,
1383                f64::NAN,
1384                kurbo::Cap::Butt,
1385                kurbo::Join::Miter,
1386                4.0,
1387                None,
1388                0.1
1389            )
1390            .is_err()
1391        );
1392    }
1393}
1394
1395#[cfg(test)]
1396mod simplify_tests {
1397    use super::*;
1398
1399    #[test]
1400    fn simplify_keeps_collinear_polyline_small_and_openness() {
1401        // A dense zig-zag-free polyline: simplify should not blow it up.
1402        let mut p = BezPath::new();
1403        p.move_to((0.0, 0.0));
1404        for i in 1..=20 {
1405            p.line_to((i as f64 * 5.0, (i % 2) as f64));
1406        }
1407
1408        let dense = VectorPath::from_bez_path(&p);
1409        let simple = simplify_path(&dense, 1.0);
1410
1411        assert!(!simple.closed);
1412        assert!(!simple.anchors.is_empty());
1413    }
1414
1415    #[test]
1416    fn tolerance_floor_never_panics_on_degenerate_input() {
1417        let single = VectorPath {
1418            anchors: vec![Anchor::corner(DVec2::ZERO)],
1419            closed: false,
1420        };
1421        let out = simplify_path(&single, f64::NAN);
1422        assert!(out.anchors.len() <= 1, "degenerate input must not grow");
1423    }
1424}
1425
1426#[cfg(test)]
1427mod offset_tests {
1428    use super::*;
1429
1430    fn square_path() -> BezPath {
1431        Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)
1432    }
1433
1434    fn line_path() -> BezPath {
1435        let mut path = BezPath::new();
1436        path.move_to((0.0, 0.0));
1437        path.line_to((100.0, 0.0));
1438        path
1439    }
1440
1441    #[test]
1442    fn positive_offset_expands_square() {
1443        let out = offset_bez_path(&square_path(), 10.0, 0.5).unwrap();
1444        let bb = out.bounding_box();
1445
1446        assert!(bb.x0 < -9.0, "x0 = {}", bb.x0);
1447        assert!(bb.y0 < -9.0, "y0 = {}", bb.y0);
1448        assert!(bb.x1 > 109.0, "x1 = {}", bb.x1);
1449        assert!(bb.y1 > 109.0, "y1 = {}", bb.y1);
1450    }
1451
1452    #[test]
1453    fn negative_offset_insets_square() {
1454        let out = offset_bez_path(&square_path(), -10.0, 0.5).unwrap();
1455        let bb = out.bounding_box();
1456
1457        assert!(bb.x0 > 9.0, "x0 = {}", bb.x0);
1458        assert!(bb.y0 > 9.0, "y0 = {}", bb.y0);
1459        assert!(bb.x1 < 91.0, "x1 = {}", bb.x1);
1460        assert!(bb.y1 < 91.0, "y1 = {}", bb.y1);
1461    }
1462
1463    #[test]
1464    fn offset_preserves_closedness() {
1465        let out = offset_bez_path(&square_path(), 5.0, 0.5).unwrap();
1466
1467        assert!(matches!(out.elements().last(), Some(PathEl::ClosePath)));
1468    }
1469
1470    #[test]
1471    fn open_line_offsets_left_for_positive_amount() {
1472        let out = offset_bez_path(&line_path(), 10.0, 0.5).unwrap();
1473        let bb = out.bounding_box();
1474
1475        assert!(bb.y0 > 9.0 && bb.y1 > 9.0, "bb = {:?}", bb);
1476    }
1477
1478    #[test]
1479    fn zero_offset_is_identity() {
1480        let path = square_path();
1481        let out = offset_bez_path(&path, 0.0, 0.5).unwrap();
1482        assert_eq!(out.elements(), path.elements());
1483    }
1484
1485    #[test]
1486    fn invalid_offset_returns_none() {
1487        assert!(offset_bez_path(&square_path(), f64::NAN, 0.5).is_none());
1488    }
1489}