1use glam::Vec2;
52
53use crate::scene::path::{Path, PathBuilder, PathCommand};
54
55impl Path {
67 #[must_use]
70 pub fn union_with(&self, other: &Path) -> Path {
71 combine(self, other, BooleanOp::Union, BoolOptions::default())
72 }
73
74 #[must_use]
76 pub fn intersect_with(&self, other: &Path) -> Path {
77 combine(self, other, BooleanOp::Intersection, BoolOptions::default())
78 }
79
80 #[must_use]
82 pub fn cut(&self, other: &Path) -> Path {
83 combine(self, other, BooleanOp::Difference, BoolOptions::default())
84 }
85
86 #[must_use]
88 pub fn xor_with(&self, other: &Path) -> Path {
89 combine(self, other, BooleanOp::Xor, BoolOptions::default())
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum BooleanOp {
96 Union,
98 Intersection,
100 Difference,
102 Xor,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
115pub enum FillRule {
116 #[default]
118 EvenOdd,
119 NonZero,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct BoolOptions {
126 pub tolerance: f32,
130 pub fill_rule: FillRule,
133 pub flatten_tolerance: f32,
137}
138
139impl Default for BoolOptions {
140 fn default() -> Self {
141 Self {
142 tolerance: 1e-4,
143 fill_rule: FillRule::EvenOdd,
144 flatten_tolerance: 0.005,
145 }
146 }
147}
148
149#[must_use]
155pub fn combine(a: &Path, b: &Path, op: BooleanOp, opts: BoolOptions) -> Path {
156 let polys_a = subpaths(a, opts.flatten_tolerance);
157 let polys_b = subpaths(b, opts.flatten_tolerance);
158
159 if polys_a.is_empty() && polys_b.is_empty() {
160 return Path::from_commands(Vec::new());
161 }
162 if polys_a.is_empty() {
164 return match op {
165 BooleanOp::Intersection | BooleanOp::Difference => Path::from_commands(Vec::new()),
166 BooleanOp::Union | BooleanOp::Xor => rebuild_from_polylines(&polys_b),
167 };
168 }
169 if polys_b.is_empty() {
170 return match op {
171 BooleanOp::Intersection => Path::from_commands(Vec::new()),
172 BooleanOp::Union | BooleanOp::Difference | BooleanOp::Xor => {
173 rebuild_from_polylines(&polys_a)
174 }
175 };
176 }
177
178 let mut edges = build_edges(&polys_a, EdgeLabel::Subject);
179 edges.extend(build_edges(&polys_b, EdgeLabel::Clip));
180 let fragments = split_at_intersections(&edges, opts.tolerance);
181
182 let kept: Vec<Edge> = fragments
183 .into_iter()
184 .filter(|frag| keep_fragment(frag, &polys_a, &polys_b, op))
185 .collect();
186
187 let contours = stitch(&kept, opts.tolerance);
188 contours_to_path(&contours)
189}
190
191#[must_use]
197pub fn combine_n(paths: &[&Path], op: BooleanOp, opts: BoolOptions) -> Path {
198 match paths {
199 [] => Path::from_commands(Vec::new()),
200 [only] => (*only).clone(),
201 [first, rest @ ..] => {
202 let mut acc = (*first).clone();
203 for next in rest {
204 acc = combine(&acc, next, op, opts);
205 }
206 acc
207 }
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216enum EdgeLabel {
217 Subject,
218 Clip,
219}
220
221#[derive(Debug, Clone, Copy)]
222struct Edge {
223 from: Vec2,
224 to: Vec2,
225 label: EdgeLabel,
226}
227
228fn subpaths(path: &Path, flatten_tolerance: f32) -> Vec<Vec<Vec2>> {
232 let mut subs: Vec<Vec<Vec2>> = Vec::new();
233 let mut current: Option<Vec<Vec2>> = None;
234 let mut subpath_start: Option<Vec2> = None;
235 let mut last_point = Vec2::ZERO;
236 for cmd in path.commands() {
237 match *cmd {
238 PathCommand::MoveTo(p) => {
239 current = Some(vec![p]);
242 subpath_start = Some(p);
243 last_point = p;
244 }
245 PathCommand::LineTo(p) => {
246 if let Some(v) = current.as_mut() {
247 v.push(p);
248 }
249 last_point = p;
250 }
251 PathCommand::QuadTo { control, end } => {
252 if let Some(v) = current.as_mut() {
253 flatten_quad(last_point, control, end, flatten_tolerance, v);
254 }
255 last_point = end;
256 }
257 PathCommand::CubicTo { c1, c2, end } => {
258 if let Some(v) = current.as_mut() {
259 flatten_cubic(last_point, c1, c2, end, flatten_tolerance, v);
260 }
261 last_point = end;
262 }
263 PathCommand::Close => {
264 if let (Some(mut v), Some(start)) = (current.take(), subpath_start) {
265 if v.last().copied().unwrap_or(start) != start {
266 v.push(start);
267 }
268 if v.len() >= 2 && (v[v.len() - 1] - v[v.len() - 2]).length() < 1e-9 {
271 v.pop();
272 }
273 if v.len() >= 3 {
274 subs.push(v);
275 }
276 }
277 if let Some(start) = subpath_start {
278 last_point = start;
279 }
280 }
281 }
282 }
283 subs
284}
285
286fn build_edges(polys: &[Vec<Vec2>], label: EdgeLabel) -> Vec<Edge> {
287 let mut edges = Vec::new();
288 for poly in polys {
289 for window in poly.windows(2) {
290 let from = window[0];
291 let to = window[1];
292 if (to - from).length() > f32::EPSILON {
293 edges.push(Edge { from, to, label });
294 }
295 }
296 if poly.len() >= 2 {
298 let from = poly[poly.len() - 1];
299 let to = poly[0];
300 if (to - from).length() > f32::EPSILON {
301 edges.push(Edge { from, to, label });
302 }
303 }
304 }
305 edges
306}
307
308fn split_at_intersections(edges: &[Edge], tolerance: f32) -> Vec<Edge> {
311 let mut splits: Vec<Vec<f32>> = vec![Vec::new(); edges.len()];
313 for i in 0..edges.len() {
314 for j in (i + 1)..edges.len() {
315 if let Some((t_i, t_j, _pt)) = segment_intersection(
316 edges[i].from,
317 edges[i].to,
318 edges[j].from,
319 edges[j].to,
320 tolerance,
321 ) {
322 if t_i > tolerance && t_i < 1.0 - tolerance {
323 splits[i].push(t_i);
324 }
325 if t_j > tolerance && t_j < 1.0 - tolerance {
326 splits[j].push(t_j);
327 }
328 }
329 }
330 }
331
332 let mut fragments = Vec::new();
333 for (i, edge) in edges.iter().enumerate() {
334 let mut params: Vec<f32> = splits[i].clone();
335 params.push(0.0);
336 params.push(1.0);
337 params.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
338 params.dedup_by(|a, b| (*a - *b).abs() < tolerance);
339 for w in params.windows(2) {
340 let t0 = w[0];
341 let t1 = w[1];
342 if t1 - t0 < tolerance {
343 continue;
344 }
345 let p0 = edge.from + (edge.to - edge.from) * t0;
346 let p1 = edge.from + (edge.to - edge.from) * t1;
347 if (p1 - p0).length() < tolerance {
348 continue;
349 }
350 fragments.push(Edge {
351 from: p0,
352 to: p1,
353 label: edge.label,
354 });
355 }
356 }
357 fragments
358}
359
360fn keep_fragment(frag: &Edge, polys_a: &[Vec<Vec2>], polys_b: &[Vec<Vec2>], op: BooleanOp) -> bool {
362 let mid = (frag.from + frag.to) * 0.5;
366 let dir = (frag.to - frag.from).normalize_or_zero();
367 let normal = Vec2::new(-dir.y, dir.x);
368 let eps = 1e-4;
369 let p_pos = mid + normal * eps;
370 let p_neg = mid - normal * eps;
371
372 let lhs_in_a = inside_any(p_pos, polys_a);
373 let rhs_in_a = inside_any(p_neg, polys_a);
374 let lhs_in_b = inside_any(p_pos, polys_b);
375 let rhs_in_b = inside_any(p_neg, polys_b);
376
377 op_rule(op, lhs_in_a, lhs_in_b) != op_rule(op, rhs_in_a, rhs_in_b)
378}
379
380fn op_rule(op: BooleanOp, in_a: bool, in_b: bool) -> bool {
381 match op {
382 BooleanOp::Union => in_a || in_b,
383 BooleanOp::Intersection => in_a && in_b,
384 BooleanOp::Difference => in_a && !in_b,
385 BooleanOp::Xor => in_a ^ in_b,
386 }
387}
388
389fn inside_any(p: Vec2, polys: &[Vec<Vec2>]) -> bool {
390 let mut crossings = 0;
391 for poly in polys {
392 crossings += ray_crossings(p, poly);
393 }
394 crossings % 2 == 1
395}
396
397fn ray_crossings(point: Vec2, poly: &[Vec2]) -> usize {
400 let mut crossings = 0;
401 let len = poly.len();
402 if len < 3 {
403 return 0;
404 }
405 for i in 0..len {
406 let edge_start = poly[i];
407 let edge_end = poly[(i + 1) % len];
408 let intersects = ((edge_start.y > point.y) != (edge_end.y > point.y)) && {
409 let t = (point.y - edge_start.y) / (edge_end.y - edge_start.y);
410 let x_at = edge_start.x + t * (edge_end.x - edge_start.x);
411 point.x < x_at
412 };
413 if intersects {
414 crossings += 1;
415 }
416 }
417 crossings
418}
419
420fn segment_intersection(
427 a0: Vec2,
428 a1: Vec2,
429 b0: Vec2,
430 b1: Vec2,
431 tolerance: f32,
432) -> Option<(f32, f32, Vec2)> {
433 let a_dir = a1 - a0;
434 let b_dir = b1 - b0;
435 let denom = a_dir.x * b_dir.y - a_dir.y * b_dir.x;
436 if denom.abs() < tolerance.max(1e-9) {
437 return None;
438 }
439 let delta = b0 - a0;
440 let mut t = (delta.x * b_dir.y - delta.y * b_dir.x) / denom;
441 let mut u = (delta.x * a_dir.y - delta.y * a_dir.x) / denom;
442 if !(-tolerance..=1.0 + tolerance).contains(&t) {
443 return None;
444 }
445 if !(-tolerance..=1.0 + tolerance).contains(&u) {
446 return None;
447 }
448 t = t.clamp(0.0, 1.0);
449 u = u.clamp(0.0, 1.0);
450 let pt = a0 + a_dir * t;
451 Some((t, u, pt))
452}
453
454fn stitch(fragments: &[Edge], tolerance: f32) -> Vec<Vec<Vec2>> {
456 let n = fragments.len();
457 let mut used = vec![false; n];
458 let mut contours: Vec<Vec<Vec2>> = Vec::new();
459
460 for i in 0..n {
461 if used[i] {
462 continue;
463 }
464 let mut contour = vec![fragments[i].from, fragments[i].to];
465 used[i] = true;
466 loop {
467 let tail = *contour.last().unwrap();
468 let mut next: Option<usize> = None;
470 for (j, frag) in fragments.iter().enumerate() {
471 if used[j] {
472 continue;
473 }
474 if (frag.from - tail).length() < tolerance {
475 next = Some(j);
476 break;
477 }
478 if (frag.to - tail).length() < tolerance {
479 next = Some(j);
481 break;
482 }
483 }
484 let Some(j) = next else { break };
485 used[j] = true;
486 let frag = fragments[j];
487 let next_pt = if (frag.from - tail).length() < tolerance {
488 frag.to
489 } else {
490 frag.from
491 };
492 if (next_pt - contour[0]).length() < tolerance {
493 break;
495 }
496 contour.push(next_pt);
497 }
498 if contour.len() >= 3 {
499 contours.push(contour);
500 }
501 }
502
503 contours
504}
505
506fn contours_to_path(contours: &[Vec<Vec2>]) -> Path {
507 if contours.is_empty() {
508 return Path::from_commands(Vec::new());
509 }
510 let mut builder = PathBuilder::new();
511 for contour in contours {
512 if contour.is_empty() {
513 continue;
514 }
515 builder = builder.move_to(contour[0]);
516 for p in &contour[1..] {
517 builder = builder.line_to(*p);
518 }
519 builder = builder.close();
520 }
521 builder.build()
522}
523
524fn rebuild_from_polylines(polys: &[Vec<Vec2>]) -> Path {
525 let mut builder = PathBuilder::new();
526 for poly in polys {
527 if poly.is_empty() {
528 continue;
529 }
530 builder = builder.move_to(poly[0]);
531 for p in &poly[1..] {
532 builder = builder.line_to(*p);
533 }
534 builder = builder.close();
535 }
536 builder.build()
537}
538
539fn flatten_quad(p0: Vec2, p1: Vec2, p2: Vec2, tolerance: f32, out: &mut Vec<Vec2>) {
541 if perp_distance(p1, p0, p2) <= tolerance {
542 out.push(p2);
543 return;
544 }
545 let p01 = (p0 + p1) * 0.5;
546 let p12 = (p1 + p2) * 0.5;
547 let mid = (p01 + p12) * 0.5;
548 flatten_quad(p0, p01, mid, tolerance, out);
549 flatten_quad(mid, p12, p2, tolerance, out);
550}
551
552fn flatten_cubic(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, tolerance: f32, out: &mut Vec<Vec2>) {
553 let d1 = perp_distance(p1, p0, p3);
554 let d2 = perp_distance(p2, p0, p3);
555 if d1.max(d2) <= tolerance {
556 out.push(p3);
557 return;
558 }
559 let q01 = (p0 + p1) * 0.5;
560 let q12 = (p1 + p2) * 0.5;
561 let q23 = (p2 + p3) * 0.5;
562 let r012 = (q01 + q12) * 0.5;
563 let r123 = (q12 + q23) * 0.5;
564 let mid = (r012 + r123) * 0.5;
565 flatten_cubic(p0, q01, r012, mid, tolerance, out);
566 flatten_cubic(mid, r123, q23, p3, tolerance, out);
567}
568
569fn perp_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
570 let ab = b - a;
571 let len = ab.length();
572 if len < f32::EPSILON {
573 return (p - a).length();
574 }
575 let cross = (p.x - a.x) * ab.y - (p.y - a.y) * ab.x;
576 cross.abs() / len
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 fn square(c: Vec2, h: f32) -> Path {
585 PathBuilder::new()
586 .move_to(Vec2::new(c.x - h, c.y - h))
587 .line_to(Vec2::new(c.x + h, c.y - h))
588 .line_to(Vec2::new(c.x + h, c.y + h))
589 .line_to(Vec2::new(c.x - h, c.y + h))
590 .close()
591 .build()
592 }
593
594 fn count_subpaths(path: &Path) -> usize {
596 path.commands()
597 .iter()
598 .filter(|c| matches!(c, PathCommand::MoveTo(_)))
599 .count()
600 }
601
602 fn any_vertex_inside(path: &Path, interior: &[Vec2]) -> bool {
604 for cmd in path.commands() {
605 let p = match cmd {
606 PathCommand::MoveTo(p) | PathCommand::LineTo(p) => *p,
607 _ => continue,
608 };
609 if ray_crossings(p, interior) % 2 == 1 {
610 return true;
611 }
612 }
613 false
614 }
615
616 #[test]
617 fn defaults_are_sensible() {
618 let opts = BoolOptions::default();
619 assert!(opts.tolerance > 0.0);
620 assert_eq!(opts.fill_rule, FillRule::EvenOdd);
621 assert!(opts.flatten_tolerance > 0.0);
622 }
623
624 #[test]
625 fn union_of_disjoint_squares_returns_two_subpaths() {
626 let a = square(Vec2::new(-1.0, 0.0), 0.4);
627 let b = square(Vec2::new(1.0, 0.0), 0.4);
628 let u = combine(&a, &b, BooleanOp::Union, BoolOptions::default());
629 assert_eq!(count_subpaths(&u), 2);
630 }
631
632 #[test]
633 fn intersection_of_disjoint_squares_is_empty() {
634 let a = square(Vec2::new(-1.0, 0.0), 0.4);
635 let b = square(Vec2::new(1.0, 0.0), 0.4);
636 let i = combine(&a, &b, BooleanOp::Intersection, BoolOptions::default());
637 assert_eq!(count_subpaths(&i), 0);
638 }
639
640 #[test]
641 fn intersection_of_overlapping_squares_is_a_smaller_square() {
642 let a = square(Vec2::ZERO, 0.5); let b = square(Vec2::new(0.3, 0.0), 0.5); let i = combine(&a, &b, BooleanOp::Intersection, BoolOptions::default());
645 assert_eq!(count_subpaths(&i), 1);
646 let centre = Vec2::new(0.15, 0.0);
649 let polys: Vec<Vec<Vec2>> = subpaths(&i, 0.005);
650 assert!(inside_any(centre, &polys), "centre of overlap missing");
651 let outside = Vec2::new(-0.4, 0.0);
652 assert!(!inside_any(outside, &polys), "non-overlap region kept");
653 }
654
655 #[test]
656 fn difference_carves_b_out_of_a() {
657 let a = square(Vec2::ZERO, 0.5);
658 let b = square(Vec2::new(0.3, 0.0), 0.5);
659 let d = combine(&a, &b, BooleanOp::Difference, BoolOptions::default());
660 assert!(
661 count_subpaths(&d) >= 1,
662 "diff should leave at least one contour"
663 );
664 let polys: Vec<Vec<Vec2>> = subpaths(&d, 0.005);
665 assert!(inside_any(Vec2::new(-0.4, 0.0), &polys));
667 assert!(!inside_any(Vec2::new(0.15, 0.0), &polys));
669 assert!(!inside_any(Vec2::new(0.7, 0.0), &polys));
671 }
672
673 #[test]
674 fn xor_keeps_outer_regions_drops_overlap() {
675 let a = square(Vec2::ZERO, 0.5);
676 let b = square(Vec2::new(0.3, 0.0), 0.5);
677 let x = combine(&a, &b, BooleanOp::Xor, BoolOptions::default());
678 let polys: Vec<Vec<Vec2>> = subpaths(&x, 0.005);
679 assert!(!inside_any(Vec2::new(0.15, 0.0), &polys));
681 assert!(inside_any(Vec2::new(-0.4, 0.0), &polys));
683 assert!(inside_any(Vec2::new(0.7, 0.0), &polys));
685 }
686
687 #[test]
688 fn empty_path_inputs_behave_sensibly() {
689 let empty = Path::from_commands(Vec::new());
690 let a = square(Vec2::ZERO, 0.5);
691 let opts = BoolOptions::default();
692
693 let u = combine(&a, &empty, BooleanOp::Union, opts);
695 assert_eq!(count_subpaths(&u), 1);
696
697 let i = combine(&a, &empty, BooleanOp::Intersection, opts);
699 assert_eq!(count_subpaths(&i), 0);
700
701 let d = combine(&empty, &a, BooleanOp::Difference, opts);
703 assert_eq!(count_subpaths(&d), 0);
704 }
705
706 #[test]
707 fn combine_n_empty_slice_returns_empty() {
708 let r = combine_n(&[], BooleanOp::Union, BoolOptions::default());
709 assert_eq!(count_subpaths(&r), 0);
710 }
711
712 #[test]
713 fn combine_n_single_returns_same_shape() {
714 let a = square(Vec2::ZERO, 0.5);
715 let r = combine_n(&[&a], BooleanOp::Union, BoolOptions::default());
716 assert_eq!(r.commands().len(), a.commands().len());
718 }
719
720 #[test]
721 fn combine_n_union_three_disjoint_is_three_subpaths() {
722 let a = square(Vec2::new(-1.5, 0.0), 0.3);
723 let b = square(Vec2::new(0.0, 0.0), 0.3);
724 let c = square(Vec2::new(1.5, 0.0), 0.3);
725 let r = combine_n(&[&a, &b, &c], BooleanOp::Union, BoolOptions::default());
726 assert_eq!(count_subpaths(&r), 3);
727 }
728
729 #[test]
730 fn ray_crossings_distinguishes_inside_outside() {
731 let sq = vec![
732 Vec2::new(-1.0, -1.0),
733 Vec2::new(1.0, -1.0),
734 Vec2::new(1.0, 1.0),
735 Vec2::new(-1.0, 1.0),
736 ];
737 assert_eq!(ray_crossings(Vec2::ZERO, &sq) % 2, 1, "centre is inside");
738 assert_eq!(
739 ray_crossings(Vec2::new(2.0, 0.0), &sq) % 2,
740 0,
741 "far right is outside"
742 );
743 }
744
745 #[test]
746 fn segment_intersection_finds_cross() {
747 let r = segment_intersection(
748 Vec2::new(-1.0, 0.0),
749 Vec2::new(1.0, 0.0),
750 Vec2::new(0.0, -1.0),
751 Vec2::new(0.0, 1.0),
752 1e-6,
753 );
754 let (t, u, pt) = r.expect("crossed segments must intersect");
755 assert!((t - 0.5).abs() < 1e-3);
756 assert!((u - 0.5).abs() < 1e-3);
757 assert!((pt - Vec2::ZERO).length() < 1e-3);
758 }
759
760 #[test]
761 fn segment_intersection_misses_parallel() {
762 let r = segment_intersection(
763 Vec2::new(0.0, 0.0),
764 Vec2::new(1.0, 0.0),
765 Vec2::new(0.0, 1.0),
766 Vec2::new(1.0, 1.0),
767 1e-6,
768 );
769 assert!(r.is_none(), "parallel non-collinear must not cross");
770 }
771
772 #[test]
773 fn any_vertex_inside_helper_works() {
774 let unit_sq = vec![
775 Vec2::new(0.0, 0.0),
776 Vec2::new(1.0, 0.0),
777 Vec2::new(1.0, 1.0),
778 Vec2::new(0.0, 1.0),
779 ];
780 let path_inside = square(Vec2::new(0.5, 0.5), 0.1);
781 assert!(any_vertex_inside(&path_inside, &unit_sq));
782 let path_outside = square(Vec2::new(5.0, 5.0), 0.1);
783 assert!(!any_vertex_inside(&path_outside, &unit_sq));
784 }
785
786 #[test]
789 fn fluent_union_with_matches_combine() {
790 let a = square(Vec2::ZERO, 0.5);
791 let b = square(Vec2::new(0.3, 0.0), 0.5);
792 let fluent = a.union_with(&b);
793 let direct = combine(&a, &b, BooleanOp::Union, BoolOptions::default());
794 assert_eq!(fluent, direct);
795 }
796
797 #[test]
798 fn fluent_intersect_with_matches_combine() {
799 let a = square(Vec2::ZERO, 0.5);
800 let b = square(Vec2::new(0.3, 0.0), 0.5);
801 let fluent = a.intersect_with(&b);
802 let direct = combine(&a, &b, BooleanOp::Intersection, BoolOptions::default());
803 assert_eq!(fluent, direct);
804 }
805
806 #[test]
807 fn fluent_cut_matches_combine_difference() {
808 let a = square(Vec2::ZERO, 0.5);
809 let b = square(Vec2::new(0.3, 0.0), 0.5);
810 let fluent = a.cut(&b);
811 let direct = combine(&a, &b, BooleanOp::Difference, BoolOptions::default());
812 assert_eq!(fluent, direct);
813 }
814
815 #[test]
816 fn fluent_xor_with_matches_combine() {
817 let a = square(Vec2::ZERO, 0.5);
818 let b = square(Vec2::new(0.3, 0.0), 0.5);
819 let fluent = a.xor_with(&b);
820 let direct = combine(&a, &b, BooleanOp::Xor, BoolOptions::default());
821 assert_eq!(fluent, direct);
822 }
823
824 fn two_squares(centre_a: Vec2, centre_b: Vec2, half: f32) -> Path {
828 PathBuilder::new()
829 .move_to(centre_a + Vec2::new(-half, -half))
830 .line_to(centre_a + Vec2::new(half, -half))
831 .line_to(centre_a + Vec2::new(half, half))
832 .line_to(centre_a + Vec2::new(-half, half))
833 .close()
834 .move_to(centre_b + Vec2::new(-half, -half))
835 .line_to(centre_b + Vec2::new(half, -half))
836 .line_to(centre_b + Vec2::new(half, half))
837 .line_to(centre_b + Vec2::new(-half, half))
838 .close()
839 .build()
840 }
841
842 #[test]
843 fn multi_subpath_union_preserves_disjoint_regions() {
844 let a = two_squares(Vec2::new(-1.0, 0.0), Vec2::new(1.0, 0.0), 0.3);
848 let b = square(Vec2::new(-0.9, 0.0), 0.25);
849 let u = combine(&a, &b, BooleanOp::Union, BoolOptions::default());
850 let polys: Vec<Vec<Vec2>> = subpaths(&u, 0.005);
851 assert!(
854 inside_any(Vec2::new(1.0, 0.0), &polys),
855 "right square missing from multi-subpath union"
856 );
857 assert!(inside_any(Vec2::new(-0.9, 0.0), &polys));
859 }
860
861 #[test]
862 fn multi_subpath_difference_carves_only_affected_subpath() {
863 let a = two_squares(Vec2::new(-1.0, 0.0), Vec2::new(1.0, 0.0), 0.3);
866 let b = square(Vec2::new(-1.0, 0.0), 0.2);
867 let d = combine(&a, &b, BooleanOp::Difference, BoolOptions::default());
868 let polys: Vec<Vec<Vec2>> = subpaths(&d, 0.005);
869 assert!(inside_any(Vec2::new(1.0, 0.0), &polys));
871 assert!(!inside_any(Vec2::new(-1.0, 0.0), &polys));
873 }
874
875 #[test]
876 fn fluent_chain_compiles_and_runs() {
877 let a = square(Vec2::new(-0.2, 0.0), 0.4);
879 let b = square(Vec2::new(0.2, 0.0), 0.4);
880 let c = square(Vec2::new(0.0, 0.4), 0.2);
881 let result = a.union_with(&b).cut(&c);
882 let polys: Vec<Vec<Vec2>> = subpaths(&result, 0.005);
884 assert!(!inside_any(Vec2::new(0.0, 0.4), &polys));
885 assert!(inside_any(Vec2::new(-0.5, 0.0), &polys));
887 }
888
889 fn circle(c: Vec2, r: f32) -> Path {
895 let k = 0.5523 * r;
896 let cx = c.x;
897 let cy = c.y;
898 PathBuilder::new()
899 .move_to(Vec2::new(cx + r, cy))
900 .cubic_to(
901 Vec2::new(cx + r, cy + k),
902 Vec2::new(cx + k, cy + r),
903 Vec2::new(cx, cy + r),
904 )
905 .cubic_to(
906 Vec2::new(cx - k, cy + r),
907 Vec2::new(cx - r, cy + k),
908 Vec2::new(cx - r, cy),
909 )
910 .cubic_to(
911 Vec2::new(cx - r, cy - k),
912 Vec2::new(cx - k, cy - r),
913 Vec2::new(cx, cy - r),
914 )
915 .cubic_to(
916 Vec2::new(cx + k, cy - r),
917 Vec2::new(cx + r, cy - k),
918 Vec2::new(cx + r, cy),
919 )
920 .close()
921 .build()
922 }
923
924 fn count_line_to(path: &Path) -> usize {
927 path.commands()
928 .iter()
929 .filter(|c| matches!(c, PathCommand::LineTo(_)))
930 .count()
931 }
932
933 #[test]
934 fn curve_input_union_produces_curved_outline() {
935 let a = circle(Vec2::new(-0.2, 0.0), 0.4);
940 let b = circle(Vec2::new(0.2, 0.0), 0.4);
941 let u = combine(&a, &b, BooleanOp::Union, BoolOptions::default());
942 assert_eq!(
943 count_subpaths(&u),
944 1,
945 "two overlapping circles union → one peanut"
946 );
947 let sq_u = combine(
948 &square(Vec2::new(-0.2, 0.0), 0.4),
949 &square(Vec2::new(0.2, 0.0), 0.4),
950 BooleanOp::Union,
951 BoolOptions::default(),
952 );
953 assert!(
954 count_line_to(&u) > count_line_to(&sq_u) + 4,
955 "circle union should carry >>{} edges (sq baseline = {})",
956 count_line_to(&sq_u) + 4,
957 count_line_to(&sq_u)
958 );
959 let polys: Vec<Vec<Vec2>> = subpaths(&u, 0.005);
962 assert!(inside_any(Vec2::new(0.0, 0.0), &polys));
963 assert!(!inside_any(Vec2::new(0.9, 0.9), &polys));
964 }
965
966 #[test]
967 fn curve_input_difference_carves_circle_out_of_circle() {
968 let a = circle(Vec2::ZERO, 0.4);
971 let b = circle(Vec2::new(0.2, 0.0), 0.2);
972 let d = combine(&a, &b, BooleanOp::Difference, BoolOptions::default());
973 let polys: Vec<Vec<Vec2>> = subpaths(&d, 0.005);
974 assert!(
975 !inside_any(Vec2::new(0.2, 0.0), &polys),
976 "B's centre should be carved out"
977 );
978 assert!(
979 inside_any(Vec2::new(-0.3, 0.0), &polys),
980 "A's far side should survive"
981 );
982 }
983
984 #[test]
985 fn flatten_tolerance_controls_output_resolution() {
986 let a = circle(Vec2::new(-0.2, 0.0), 0.4);
989 let b = circle(Vec2::new(0.2, 0.0), 0.4);
990 let tight = combine(
991 &a,
992 &b,
993 BooleanOp::Union,
994 BoolOptions {
995 flatten_tolerance: 0.001,
996 ..Default::default()
997 },
998 );
999 let loose = combine(
1000 &a,
1001 &b,
1002 BooleanOp::Union,
1003 BoolOptions {
1004 flatten_tolerance: 0.1,
1005 ..Default::default()
1006 },
1007 );
1008 let n_tight = count_line_to(&tight);
1009 let n_loose = count_line_to(&loose);
1010 assert!(
1011 n_tight > n_loose,
1012 "tighter tolerance ({n_tight} edges) should subdivide more than looser ({n_loose})"
1013 );
1014 }
1015}