1pub 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
14pub 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
34pub 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, pub tan_out: DVec2, 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#[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#[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 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 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 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 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 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 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 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; let p_out = a.pos + dir_next * r; const K: f64 = 0.5523;
353 out.push(Anchor {
354 pos: p_in,
355 tan_in: DVec2::ZERO, 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 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 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 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#[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
499pub 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
509pub 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
531pub 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
544pub 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(¤t);
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(¤t);
564 if sub.anchors.len() >= 2 {
565 output.push(sub);
566 }
567 }
568
569 output
570}
571
572pub 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
618pub 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
630pub 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 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 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 (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); 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; }
1062 let r = tiny.round_corners(100.0); 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 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 assert_eq!(r.anchors.len(), 4); 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 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); 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 let result = boolean_op(&a, &b, BooleanOp::Intersection).unwrap();
1230 assert!(result.is_empty());
1231
1232 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 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 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 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 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 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}