//! Curve intersection and proximity detection for paint bucket tool //! //! This module provides functions for finding: //! - Exact intersections between cubic Bezier curves //! - Self-intersections within a single curve //! - Closest approach between curves (for gap tolerance) use vello::kurbo::{CubicBez, ParamCurve, ParamCurveNearest, Point, Shape}; /// Result of a curve intersection #[derive(Debug, Clone)] pub struct Intersection { /// Parameter t on first curve [0, 1] pub t1: f64, /// Parameter t on second curve [0, 1] (for curve-curve intersections) pub t2: Option, /// Point of intersection pub point: Point, } /// Result of a close approach between two curves #[derive(Debug, Clone)] pub struct CloseApproach { /// Parameter on first curve pub t1: f64, /// Parameter on second curve pub t2: f64, /// Point on first curve pub p1: Point, /// Point on second curve pub p2: Point, /// Distance between the curves pub distance: f64, } /// Find intersections between two cubic Bezier curves /// /// Uses recursive subdivision to find intersection points. /// This is much more robust and faster than sampling. pub fn find_curve_intersections(curve1: &CubicBez, curve2: &CubicBez) -> Vec { let mut intersections = Vec::new(); // Use subdivision-based intersection detection find_intersections_recursive( curve1, curve1, 0.0, 1.0, curve2, curve2, 0.0, 1.0, &mut intersections, 0, // recursion depth ); // Remove duplicate intersections dedup_intersections(&mut intersections, 1.0); intersections } /// Recursively find intersections using subdivision /// /// orig_curve1/2 are the original curves (for computing final intersection points) /// curve1/2 are the current subsegments being tested /// t1_start/end track the parameter range on the original curve fn find_intersections_recursive( orig_curve1: &CubicBez, curve1: &CubicBez, t1_start: f64, t1_end: f64, orig_curve2: &CubicBez, curve2: &CubicBez, t2_start: f64, t2_end: f64, intersections: &mut Vec, depth: usize, ) { // Maximum recursion depth const MAX_DEPTH: usize = 20; // Pixel-space convergence threshold: stop subdividing when both // subsegments span less than this many pixels. const PIXEL_TOL: f64 = 0.25; // Get bounding boxes of current subsegments let bbox1 = curve1.bounding_box(); let bbox2 = curve2.bounding_box(); // Inflate bounding boxes slightly to account for numerical precision let bbox1 = bbox1.inflate(0.1, 0.1); let bbox2 = bbox2.inflate(0.1, 0.1); // If bounding boxes don't overlap, no intersection if !bboxes_overlap(&bbox1, &bbox2) { return; } // Evaluate subsegment endpoints for convergence check and line-line solve let a0 = orig_curve1.eval(t1_start); let a1 = orig_curve1.eval(t1_end); let b0 = orig_curve2.eval(t2_start); let b1 = orig_curve2.eval(t2_end); // Check convergence in pixel space: both subsegment spans must be // below the tolerance. This ensures the linear approximation error // is always well within the vertex snap threshold regardless of // curve length. let a_span = (a1 - a0).hypot(); let b_span = (b1 - b0).hypot(); if depth >= MAX_DEPTH || (a_span < PIXEL_TOL && b_span < PIXEL_TOL) { let (t1, t2, point) = if let Some((s, u)) = line_line_intersect(a0, a1, b0, b1) { let s = s.clamp(0.0, 1.0); let u = u.clamp(0.0, 1.0); let mut t1 = t1_start + s * (t1_end - t1_start); let mut t2 = t2_start + u * (t2_end - t2_start); // Newton refinement: converge t1, t2 so that // curve1.eval(t1) == curve2.eval(t2) to sub-pixel accuracy. // We solve F(t1,t2) = curve1(t1) - curve2(t2) = 0 via the // Jacobian [d1, -d2] where d1/d2 are the curve tangents. let t1_orig = t1; let t2_orig = t2; for _ in 0..8 { let p1 = orig_curve1.eval(t1); let p2 = orig_curve2.eval(t2); let err = Point::new(p1.x - p2.x, p1.y - p2.y); if err.x * err.x + err.y * err.y < 1e-6 { break; } // Tangent vectors (derivative of cubic bezier) let d1 = cubic_deriv(orig_curve1, t1); let d2 = cubic_deriv(orig_curve2, t2); // Solve [d1.x, -d2.x; d1.y, -d2.y] * [dt1; dt2] = -[err.x; err.y] let det = d1.x * (-d2.y) - d1.y * (-d2.x); if det.abs() < 1e-12 { break; // tangents parallel, can't refine } let dt1 = (-d2.y * (-err.x) - (-d2.x) * (-err.y)) / det; let dt2 = (d1.x * (-err.y) - d1.y * (-err.x)) / det; t1 = (t1 + dt1).clamp(0.0, 1.0); t2 = (t2 + dt2).clamp(0.0, 1.0); } // If Newton diverged far from the initial estimate, it may have // jumped to a different crossing. Reject and fall back. if (t1 - t1_orig).abs() > (t1_end - t1_start) * 2.0 || (t2 - t2_orig).abs() > (t2_end - t2_start) * 2.0 { t1 = t1_orig; t2 = t2_orig; } let p1 = orig_curve1.eval(t1); let p2 = orig_curve2.eval(t2); (t1, t2, Point::new((p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5)) } else { // Lines are parallel/degenerate — fall back to midpoint let t1 = (t1_start + t1_end) / 2.0; let t2 = (t2_start + t2_end) / 2.0; (t1, t2, orig_curve1.eval(t1)) }; intersections.push(Intersection { t1, t2: Some(t2), point, }); return; } // Subdivide both curves at midpoint (of the current subsegment, which is 0..1) let t1_mid = (t1_start + t1_end) / 2.0; let t2_mid = (t2_start + t2_end) / 2.0; // Create subsegments - these are new curves parameterized 0..1 let curve1_left = curve1.subsegment(0.0..0.5); let curve1_right = curve1.subsegment(0.5..1.0); let curve2_left = curve2.subsegment(0.0..0.5); let curve2_right = curve2.subsegment(0.5..1.0); // Check all four combinations find_intersections_recursive( orig_curve1, &curve1_left, t1_start, t1_mid, orig_curve2, &curve2_left, t2_start, t2_mid, intersections, depth + 1 ); find_intersections_recursive( orig_curve1, &curve1_left, t1_start, t1_mid, orig_curve2, &curve2_right, t2_mid, t2_end, intersections, depth + 1 ); find_intersections_recursive( orig_curve1, &curve1_right, t1_mid, t1_end, orig_curve2, &curve2_left, t2_start, t2_mid, intersections, depth + 1 ); find_intersections_recursive( orig_curve1, &curve1_right, t1_mid, t1_end, orig_curve2, &curve2_right, t2_mid, t2_end, intersections, depth + 1 ); } /// Check if two bounding boxes overlap fn bboxes_overlap(bbox1: &vello::kurbo::Rect, bbox2: &vello::kurbo::Rect) -> bool { bbox1.x0 <= bbox2.x1 && bbox1.x1 >= bbox2.x0 && bbox1.y0 <= bbox2.y1 && bbox1.y1 >= bbox2.y0 } /// Find self-intersections within a single cubic Bezier curve /// /// A curve self-intersects when it crosses itself, forming a loop. pub fn find_self_intersections(curve: &CubicBez) -> Vec { let mut intersections = Vec::new(); // Sample the curve at regular intervals let samples = 50; for i in 0..samples { let t1 = i as f64 / samples as f64; let p1 = curve.eval(t1); // Check against all later points for j in (i + 5)..samples { // Skip nearby points to avoid false positives let t2 = j as f64 / samples as f64; let p2 = curve.eval(t2); let dist = (p1 - p2).hypot(); // If points are very close, we may have a self-intersection if dist < 0.5 { // Refine to get more accurate parameters let (refined_t1, refined_t2) = refine_self_intersection(curve, t1, t2); intersections.push(Intersection { t1: refined_t1, t2: Some(refined_t2), point: curve.eval(refined_t1), }); } } } // Remove duplicates dedup_intersections(&mut intersections, 0.5); intersections } /// Find the closest approach between two curves if within tolerance /// /// Returns Some if the minimum distance between curves is less than tolerance. pub fn find_closest_approach( curve1: &CubicBez, curve2: &CubicBez, tolerance: f64, ) -> Option { let mut min_dist = f64::MAX; let mut best_t1 = 0.0; let mut best_t2 = 0.0; // Sample curve1 at regular intervals let samples = 50; for i in 0..=samples { let t1 = i as f64 / samples as f64; let p1 = curve1.eval(t1); // Find nearest point on curve2 let nearest = curve2.nearest(p1, 1e-6); let dist = (p1 - curve2.eval(nearest.t)).hypot(); if dist < min_dist { min_dist = dist; best_t1 = t1; best_t2 = nearest.t; } } // If minimum distance is within tolerance, return it if min_dist < tolerance { Some(CloseApproach { t1: best_t1, t2: best_t2, p1: curve1.eval(best_t1), p2: curve2.eval(best_t2), distance: min_dist, }) } else { None } } /// Refine self-intersection parameters fn refine_self_intersection(curve: &CubicBez, mut t1: f64, mut t2: f64) -> (f64, f64) { // Refine by moving parameters closer to where curves actually meet for _ in 0..5 { let p1 = curve.eval(t1); let p2 = curve.eval(t2); let mid = Point::new((p1.x + p2.x) / 2.0, (p1.y + p2.y) / 2.0); // Move both parameters toward the midpoint let nearest1 = curve.nearest(mid, 1e-6); let nearest2 = curve.nearest(mid, 1e-6); // Take whichever is closer to original parameter if (nearest1.t - t1).abs() < (nearest2.t - t1).abs() { t1 = nearest1.t; } else if (nearest2.t - t2).abs() < (nearest1.t - t2).abs() { t2 = nearest2.t; } } (t1.clamp(0.0, 1.0), t2.clamp(0.0, 1.0)) } /// Remove duplicate intersections by clustering on parameter proximity. /// /// Raw hits from subdivision can produce chains of near-duplicates spaced /// just over the spatial tolerance (e.g. 4 hits at 1.02 px apart for a /// single crossing of shallow-angle curves). Pairwise spatial dedup fails /// on these chains. Instead, we sort by t1, cluster consecutive hits whose /// t1 values are within `param_tol`, and keep the median of each cluster. fn dedup_intersections(intersections: &mut Vec, _tolerance: f64) { if intersections.is_empty() { return; } const PARAM_TOL: f64 = 0.05; // Sort by t1 (primary) then t2 (secondary) intersections.sort_by(|a, b| { a.t1.partial_cmp(&b.t1) .unwrap() .then_with(|| { let at2 = a.t2.unwrap_or(0.0); let bt2 = b.t2.unwrap_or(0.0); at2.partial_cmp(&bt2).unwrap() }) }); // Cluster consecutive intersections that are close in both t1 and t2 let mut clusters: Vec> = Vec::new(); let mut current_cluster = vec![0usize]; for i in 1..intersections.len() { let prev = &intersections[*current_cluster.last().unwrap()]; let curr = &intersections[i]; let t1_close = (curr.t1 - prev.t1).abs() < PARAM_TOL; let t2_close = match (curr.t2, prev.t2) { (Some(a), Some(b)) => (a - b).abs() < PARAM_TOL, _ => true, }; if t1_close && t2_close { current_cluster.push(i); } else { clusters.push(std::mem::take(&mut current_cluster)); current_cluster = vec![i]; } } clusters.push(current_cluster); // Keep the median of each cluster let mut result = Vec::with_capacity(clusters.len()); for cluster in &clusters { let median_idx = cluster[cluster.len() / 2]; result.push(intersections[median_idx].clone()); } *intersections = result; } /// Derivative (tangent vector) of a cubic Bezier at parameter t. /// /// B'(t) = 3[(1-t)²(P1-P0) + 2(1-t)t(P2-P1) + t²(P3-P2)] fn cubic_deriv(c: &CubicBez, t: f64) -> Point { let u = 1.0 - t; let d0 = Point::new(c.p1.x - c.p0.x, c.p1.y - c.p0.y); let d1 = Point::new(c.p2.x - c.p1.x, c.p2.y - c.p1.y); let d2 = Point::new(c.p3.x - c.p2.x, c.p3.y - c.p2.y); Point::new( 3.0 * (u * u * d0.x + 2.0 * u * t * d1.x + t * t * d2.x), 3.0 * (u * u * d0.y + 2.0 * u * t * d1.y + t * t * d2.y), ) } /// 2D line-line intersection. /// /// Given line segment A (a0→a1) and line segment B (b0→b1), /// returns `Some((s, u))` where `s` is the parameter on A and /// `u` is the parameter on B at the intersection point. /// Returns `None` if the lines are parallel or degenerate. fn line_line_intersect(a0: Point, a1: Point, b0: Point, b1: Point) -> Option<(f64, f64)> { let dx_a = a1.x - a0.x; let dy_a = a1.y - a0.y; let dx_b = b1.x - b0.x; let dy_b = b1.y - b0.y; let denom = dx_a * dy_b - dy_a * dx_b; if denom.abs() < 1e-12 { return None; // parallel or degenerate } let dx_ab = b0.x - a0.x; let dy_ab = b0.y - a0.y; let s = (dx_ab * dy_b - dy_ab * dx_b) / denom; let u = (dx_ab * dy_a - dy_ab * dx_a) / denom; Some((s, u)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_curve_intersection_simple() { // Two curves that cross let curve1 = CubicBez::new( Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(100.0, 100.0), Point::new(200.0, 200.0), ); let curve2 = CubicBez::new( Point::new(200.0, 0.0), Point::new(100.0, 100.0), Point::new(100.0, 100.0), Point::new(0.0, 200.0), ); let intersections = find_curve_intersections(&curve1, &curve2); // Should find at least one intersection near the center assert!(!intersections.is_empty()); } #[test] fn test_self_intersection() { // A curve that loops back on itself let curve = CubicBez::new( Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(-100.0, 100.0), Point::new(0.0, 0.0), ); let intersections = find_self_intersections(&curve); // May or may not find intersection depending on curve shape // This is mostly testing that the function doesn't crash assert!(intersections.len() <= 10); // Sanity check } #[test] fn test_closest_approach() { // Two curves that are close but don't intersect let curve1 = CubicBez::new( Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(150.0, 0.0), ); let curve2 = CubicBez::new( Point::new(0.0, 1.5), Point::new(50.0, 1.5), Point::new(100.0, 1.5), Point::new(150.0, 1.5), ); let approach = find_closest_approach(&curve1, &curve2, 2.0); assert!(approach.is_some()); let approach = approach.unwrap(); assert!(approach.distance < 2.0); } }