//! Intersection graph for paint bucket fill //! //! This module implements an incremental graph-building approach for finding //! closed regions to fill. Instead of flood-filling, we: //! 1. Start at a curve found via raycast from the click point //! 2. Find all intersections on that curve (with other curves and itself) //! 3. Walk the graph, choosing the "most clockwise" turn at each junction //! 4. Incrementally add nearby curves as we encounter them //! 5. Track visited segments to detect when we've completed a loop use crate::curve_intersections::{find_closest_approach, find_curve_intersections, find_self_intersections}; use crate::curve_segment::CurveSegment; use crate::gap_handling::GapHandlingMode; use crate::tolerance_quadtree::ToleranceQuadtree; use std::collections::HashSet; use vello::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point}; /// A node in the intersection graph representing a point where curves meet #[derive(Debug, Clone)] pub struct IntersectionNode { /// Location of this node pub point: Point, /// Edges connected to this node pub edges: Vec, } /// Reference to an edge in the graph #[derive(Debug, Clone, Copy, PartialEq)] pub struct EdgeRef { /// Index of the curve this edge follows pub curve_id: usize, /// Parameter value where this edge starts [0, 1] pub t_start: f64, /// Parameter value where this edge ends [0, 1] pub t_end: f64, /// Direction at the start of this edge (for angle calculations) pub start_tangent: Point, } /// A visited segment (for loop detection) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct VisitedSegment { curve_id: usize, /// t_start quantized to 0.01 precision for hashing t_start_quantized: i32, /// t_end quantized to 0.01 precision for hashing t_end_quantized: i32, } impl VisitedSegment { fn new(curve_id: usize, t_start: f64, t_end: f64) -> Self { Self { curve_id, t_start_quantized: (t_start * 100.0).round() as i32, t_end_quantized: (t_end * 100.0).round() as i32, } } } /// Result of walking the intersection graph pub struct WalkResult { /// The closed path found by walking the graph pub path: Option, /// Debug information about the walk pub debug_info: WalkDebugInfo, } /// Debug information about the walk process #[derive(Default)] pub struct WalkDebugInfo { /// Number of segments walked pub segments_walked: usize, /// Number of intersections found pub intersections_found: usize, /// Number of gaps bridged pub gaps_bridged: usize, /// Whether the walk completed successfully pub completed: bool, /// Points visited during the walk (for visualization) pub visited_points: Vec, /// Segments walked during the graph traversal (curve_id, t_start, t_end) pub walked_segments: Vec<(usize, f64, f64)>, } /// Configuration for the intersection graph walk pub struct WalkConfig { /// Gap tolerance in pixels pub tolerance: f64, /// Gap handling mode pub gap_mode: GapHandlingMode, /// Maximum number of segments to walk before giving up pub max_segments: usize, } impl Default for WalkConfig { fn default() -> Self { Self { tolerance: 2.0, gap_mode: GapHandlingMode::default(), max_segments: 10000, } } } /// Walk the intersection graph to find a closed path /// /// # Arguments /// /// * `start_point` - Point to start the walk (click point) /// * `curves` - All curves in the scene /// * `quadtree` - Spatial index for finding nearby curves /// * `config` - Walk configuration /// /// # Returns /// /// A `WalkResult` with the closed path if one was found pub fn walk_intersection_graph( start_point: Point, curves: &[CurveSegment], quadtree: &ToleranceQuadtree, config: &WalkConfig, ) -> WalkResult { let mut debug_info = WalkDebugInfo::default(); // Step 1: Find the first curve via raycast let first_curve_id = match find_curve_at_point(start_point, curves) { Some(id) => id, None => { println!("No curve found at start point"); return WalkResult { path: None, debug_info, }; } }; println!("Starting walk from curve {}", first_curve_id); // Step 2: Find a starting point on that curve let first_curve = &curves[first_curve_id]; let nearest = first_curve.to_cubic_bez().nearest(start_point, 1e-6); let start_t = nearest.t; let start_pos = first_curve.to_cubic_bez().eval(start_t); debug_info.visited_points.push(start_pos); println!("Start position: ({:.1}, {:.1}) at t={:.3}", start_pos.x, start_pos.y, start_t); // Step 3: Walk the graph let mut path = BezPath::new(); path.move_to(start_pos); let mut current_curve_id = first_curve_id; let mut current_t = start_t; let mut visited_segments = HashSet::new(); let mut processed_curves = HashSet::new(); processed_curves.insert(first_curve_id); // Convert CurveSegments to CubicBez for easier processing let cubic_curves: Vec = curves.iter().map(|seg| seg.to_cubic_bez()).collect(); for _iteration in 0..config.max_segments { debug_info.segments_walked += 1; // Find all intersections on current curve let intersections = find_intersections_on_curve( current_curve_id, &cubic_curves, &processed_curves, quadtree, config.tolerance, &mut debug_info, ); println!("Found {} intersections on curve {}", intersections.len(), current_curve_id); // Find the next intersection point in the forward direction (just to get to an intersection) let next_intersection_point = intersections .iter() .filter(|i| i.t_on_current > current_t + 0.01) // Small epsilon to avoid same point .min_by(|a, b| a.t_on_current.partial_cmp(&b.t_on_current).unwrap()); let next_intersection_point = match next_intersection_point { Some(i) => i, None => { // Try wrapping around (for closed curves) let wrapped = intersections .iter() .filter(|i| i.t_on_current < current_t - 0.01) .min_by(|a, b| a.t_on_current.partial_cmp(&b.t_on_current).unwrap()); match wrapped { Some(i) => i, None => { println!("No next intersection found, walk failed"); break; } } } }; println!("Reached intersection at t={:.3} on curve {}, point: ({:.1}, {:.1})", next_intersection_point.t_on_current, current_curve_id, next_intersection_point.point.x, next_intersection_point.point.y); // Add segment from current position to intersection let segment = extract_curve_segment( &cubic_curves[current_curve_id], current_t, next_intersection_point.t_on_current, ); add_segment_to_path(&mut path, &segment, config.gap_mode); // Record this segment for debug visualization debug_info.walked_segments.push(( current_curve_id, current_t, next_intersection_point.t_on_current, )); // Mark this segment as visited let visited = VisitedSegment::new( current_curve_id, current_t, next_intersection_point.t_on_current, ); // Check if we've completed a loop if visited_segments.contains(&visited) { println!("Loop detected! Walk complete"); debug_info.completed = true; path.close_path(); break; } visited_segments.insert(visited); debug_info.visited_points.push(next_intersection_point.point); // Now at the intersection point, we need to choose which curve to follow next // by finding all curves at this point and choosing the rightmost turn // Calculate incoming direction (tangent at the end of the segment we just walked) let incoming_deriv = cubic_curves[current_curve_id].deriv().eval(next_intersection_point.t_on_current); let incoming_angle = incoming_deriv.y.atan2(incoming_deriv.x); // For boundary walking, we measure angles from the REVERSE of the incoming direction // (i.e., where we came FROM, not where we're going) let reverse_incoming_angle = (incoming_angle + std::f64::consts::PI) % (2.0 * std::f64::consts::PI); println!("Incoming angle: {:.2} rad ({:.1} deg), reverse: {:.2} rad ({:.1} deg)", incoming_angle, incoming_angle.to_degrees(), reverse_incoming_angle, reverse_incoming_angle.to_degrees()); // Find ALL intersections at this point (within tolerance) let intersection_point = next_intersection_point.point; let mut candidates: Vec<(usize, f64, f64, bool)> = Vec::new(); // (curve_id, t, angle_from_incoming, is_gap) // Query the quadtree to find ALL curves at this intersection point // Create a small bounding box around the point use crate::quadtree::BoundingBox; let search_bbox = BoundingBox { x_min: intersection_point.x - config.tolerance, x_max: intersection_point.x + config.tolerance, y_min: intersection_point.y - config.tolerance, y_max: intersection_point.y + config.tolerance, }; let nearby_curves = quadtree.get_curves_in_region(&search_bbox); println!("Querying quadtree at ({:.1}, {:.1}) found {} nearby curves", intersection_point.x, intersection_point.y, nearby_curves.len()); // ALSO check ALL curves to see if any pass through this intersection // (in case quadtree isn't finding everything) let mut all_curves_at_point = nearby_curves.clone(); for curve_id in 0..cubic_curves.len() { if !nearby_curves.contains(&curve_id) { let curve_bez = &cubic_curves[curve_id]; let nearest = curve_bez.nearest(intersection_point, 1e-6); let point_on_curve = curve_bez.eval(nearest.t); let dist = (point_on_curve - intersection_point).hypot(); if dist < config.tolerance { println!(" EXTRA: Curve {} found by brute-force check at t={:.3}, dist={:.4}", curve_id, nearest.t, dist); all_curves_at_point.insert(curve_id); } } } let nearby_curves: Vec = all_curves_at_point.into_iter().collect(); for &curve_id in &nearby_curves { // Find the t value on this curve closest to the intersection point let curve_bez = &cubic_curves[curve_id]; let nearest = curve_bez.nearest(intersection_point, 1e-6); let t_on_curve = nearest.t; let point_on_curve = curve_bez.eval(t_on_curve); let dist = (point_on_curve - intersection_point).hypot(); println!(" Curve {} at t={:.3}, dist={:.4}", curve_id, t_on_curve, dist); if dist < config.tolerance { // This curve passes through (or very near) the intersection point let is_gap = dist > config.tolerance * 0.1; // Consider it a gap if not very close // Forward direction (increasing t) let forward_deriv = curve_bez.deriv().eval(t_on_curve); let forward_angle = forward_deriv.y.atan2(forward_deriv.x); let forward_angle_diff = normalize_angle(forward_angle - reverse_incoming_angle); // Don't add this candidate if it's going back exactly where we came from // (same curve, same t, same direction) let is_reverse_on_current = curve_id == current_curve_id && (t_on_curve - next_intersection_point.t_on_current).abs() < 0.01 && forward_angle_diff < 0.1; if !is_reverse_on_current { candidates.push((curve_id, t_on_curve, forward_angle_diff, is_gap)); } // Backward direction (decreasing t) - reverse the tangent let backward_angle = (forward_angle + std::f64::consts::PI) % (2.0 * std::f64::consts::PI); let backward_angle_diff = normalize_angle(backward_angle - reverse_incoming_angle); let is_reverse_on_current_backward = curve_id == current_curve_id && (t_on_curve - next_intersection_point.t_on_current).abs() < 0.01 && backward_angle_diff < 0.1; if !is_reverse_on_current_backward { candidates.push((curve_id, t_on_curve, backward_angle_diff, is_gap)); } } } println!("Found {} candidate outgoing edges", candidates.len()); for (i, (cid, t, angle, is_gap)) in candidates.iter().enumerate() { println!(" Candidate {}: curve={}, t={:.3}, angle_diff={:.2} rad ({:.1} deg), gap={}", i, cid, t, angle, angle.to_degrees(), is_gap); } // Choose the edge with the smallest positive angle (sharpest right turn for clockwise) // Now that we measure from reverse_incoming_angle: // - 0° = going back the way we came (filter out) // - Small angles like 30°-90° = sharp right turn (what we want) // - 180° = continuing straight (valid - don't filter) // IMPORTANT: // 1. Prefer non-gap edges over gap edges // 2. When angles are equal, prefer switching to a different curve let best_edge = candidates .iter() .filter(|(_cid, _, angle_diff, _)| { // Don't go back the way we came (angle near 0) let is_reverse = *angle_diff < 0.1; !is_reverse }) .min_by(|a, b| { // First, prefer non-gap edges over gap edges match (a.3, b.3) { (false, true) => std::cmp::Ordering::Less, // a is non-gap, b is gap -> prefer a (true, false) => std::cmp::Ordering::Greater, // a is gap, b is non-gap -> prefer b _ => { // Both same gap status -> compare angles let angle_diff = (a.2 - b.2).abs(); const ANGLE_EPSILON: f64 = 0.01; // ~0.57 degrees tolerance for "equal" angles if angle_diff < ANGLE_EPSILON { // Angles are effectively equal - prefer different curve over same curve let a_is_current = a.0 == current_curve_id; let b_is_current = b.0 == current_curve_id; match (a_is_current, b_is_current) { (true, false) => std::cmp::Ordering::Greater, // a is current, b is different -> prefer b (false, true) => std::cmp::Ordering::Less, // a is different, b is current -> prefer a _ => a.2.partial_cmp(&b.2).unwrap(), // Both same or both different -> fall back to angle } } else { a.2.partial_cmp(&b.2).unwrap() } } } }); let (next_curve_id, next_t, chosen_angle, is_gap) = match best_edge { Some(&(cid, t, angle, gap)) => (cid, t, angle, gap), None => { println!("No valid outgoing edge found!"); break; } }; println!("Chose: curve={}, t={:.3}, angle={:.2} rad, gap={}", next_curve_id, next_t, chosen_angle, is_gap); // Handle gap if needed if is_gap { debug_info.gaps_bridged += 1; match config.gap_mode { GapHandlingMode::BridgeSegment => { // Add a line segment to bridge the gap let current_end = intersection_point; let next_start = cubic_curves[next_curve_id].eval(next_t); path.line_to(next_start); println!("Bridged gap: ({:.1}, {:.1}) -> ({:.1}, {:.1})", current_end.x, current_end.y, next_start.x, next_start.y); } GapHandlingMode::SnapAndSplit => { // Snap to midpoint (geometry modification handled in segment extraction) println!("Snapped to gap midpoint"); } } } // Move to next curve processed_curves.insert(next_curve_id); current_curve_id = next_curve_id; current_t = next_t; // Check if we've returned to start let current_pos = cubic_curves[current_curve_id].eval(current_t); let dist_to_start = (current_pos - start_pos).hypot(); if dist_to_start < config.tolerance && current_curve_id == first_curve_id { println!("Returned to start! Walk complete"); debug_info.completed = true; path.close_path(); break; } } println!("Walk finished: {} segments, {} intersections, {} gaps", debug_info.segments_walked, debug_info.intersections_found, debug_info.gaps_bridged); WalkResult { path: if debug_info.completed { Some(path) } else { None }, debug_info, } } /// Information about an intersection found on a curve #[derive(Debug, Clone)] struct CurveIntersection { /// Parameter on current curve t_on_current: f64, /// Parameter on other curve _t_on_other: f64, /// ID of the other curve _other_curve_id: usize, /// Intersection point point: Point, /// Whether this is a gap (within tolerance but not exact intersection) _is_gap: bool, } /// Find all intersections on a given curve fn find_intersections_on_curve( curve_id: usize, curves: &[CubicBez], _processed_curves: &HashSet, quadtree: &ToleranceQuadtree, tolerance: f64, debug_info: &mut WalkDebugInfo, ) -> Vec { let mut intersections = Vec::new(); let current_curve = &curves[curve_id]; // Find nearby curves using quadtree let nearby = quadtree.get_nearby_curves(current_curve); for &other_id in &nearby { if other_id == curve_id { // Check for self-intersections let self_ints = find_self_intersections(current_curve); for int in self_ints { intersections.push(CurveIntersection { t_on_current: int.t1, _t_on_other: int.t2.unwrap_or(int.t1), _other_curve_id: curve_id, point: int.point, _is_gap: false, }); debug_info.intersections_found += 1; } } else { let other_curve = &curves[other_id]; // Find exact intersections let exact_ints = find_curve_intersections(current_curve, other_curve); for int in exact_ints { intersections.push(CurveIntersection { t_on_current: int.t1, _t_on_other: int.t2.unwrap_or(0.0), _other_curve_id: other_id, point: int.point, _is_gap: false, }); debug_info.intersections_found += 1; } // Find close approaches (gaps within tolerance) if let Some(approach) = find_closest_approach(current_curve, other_curve, tolerance) { intersections.push(CurveIntersection { t_on_current: approach.t1, _t_on_other: approach.t2, _other_curve_id: other_id, point: approach.p1, _is_gap: true, }); } } } // Sort by t_on_current for easier processing intersections.sort_by(|a, b| a.t_on_current.partial_cmp(&b.t_on_current).unwrap()); intersections } /// Find a curve at the given point via raycast fn find_curve_at_point(point: Point, curves: &[CurveSegment]) -> Option { let mut min_dist = f64::MAX; let mut closest_id = None; for (i, curve) in curves.iter().enumerate() { let cubic = curve.to_cubic_bez(); let nearest = cubic.nearest(point, 1e-6); let dist = (cubic.eval(nearest.t) - point).hypot(); if dist < min_dist { min_dist = dist; closest_id = Some(i); } } // Only accept if within reasonable distance if min_dist < 50.0 { closest_id } else { None } } /// Extract a subsegment of a curve between two t parameters fn extract_curve_segment(curve: &CubicBez, t_start: f64, t_end: f64) -> CubicBez { // Clamp parameters let t_start = t_start.clamp(0.0, 1.0); let t_end = t_end.clamp(0.0, 1.0); if t_start >= t_end { // Degenerate segment, return a point let p = curve.eval(t_start); return CubicBez::new(p, p, p, p); } // Use de Casteljau's algorithm to extract subsegment curve.subsegment(t_start..t_end) } /// Add a curve segment to the path fn add_segment_to_path(path: &mut BezPath, segment: &CubicBez, _gap_mode: GapHandlingMode) { // Add as cubic bezier curve path.curve_to(segment.p1, segment.p2, segment.p3); } #[cfg(test)] mod tests { use super::*; use vello::kurbo::Circle; #[test] fn test_visited_segment_quantization() { // VisitedSegment quantizes to 0.01 precision (t * 100 rounded) // Values differing by less than 0.005 will round to the same value let seg1 = VisitedSegment::new(0, 0.123, 0.456); let seg2 = VisitedSegment::new(0, 0.135, 0.467); // Differs by > 0.01 to ensure different quantization let seg3 = VisitedSegment::new(0, 0.123, 0.456); let seg4 = VisitedSegment::new(0, 0.124, 0.457); // Within 0.01 - should be same as seg1 // Different quantized values assert_ne!(seg1, seg2); // Same exact values assert_eq!(seg1, seg3); // seg4 has values within 0.01 of seg1, so they quantize to the same // 0.123 * 100 = 12.3 -> 12, 0.124 * 100 = 12.4 -> 12 // 0.456 * 100 = 45.6 -> 46, 0.457 * 100 = 45.7 -> 46 assert_eq!(seg1, seg4); } #[test] fn test_extract_curve_segment() { let curve = CubicBez::new( Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), ); let segment = extract_curve_segment(&curve, 0.25, 0.75); // Segment should start and end at expected points let start = segment.eval(0.0); let end = segment.eval(1.0); let expected_start = curve.eval(0.25); let expected_end = curve.eval(0.75); assert!((start - expected_start).hypot() < 1.0); assert!((end - expected_end).hypot() < 1.0); } #[test] fn test_find_curve_at_point() { use crate::curve_segment::CurveType; let curves = vec![ CurveSegment::new( 0, 0, CurveType::Cubic, 0.0, 1.0, vec![ Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), ], ), ]; // Point on the curve should find it let found = find_curve_at_point(Point::new(50.0, 25.0), &curves); assert!(found.is_some()); // Point far away should not find it let not_found = find_curve_at_point(Point::new(1000.0, 1000.0), &curves); assert!(not_found.is_none()); } } /// Normalize an angle difference to the range [0, 2*PI) /// This is used to calculate the clockwise angle from one direction to another fn normalize_angle(angle: f64) -> f64 { let two_pi = 2.0 * std::f64::consts::PI; let mut result = angle % two_pi; if result < 0.0 { result += two_pi; } // Handle floating point precision: if very close to 2π, wrap to 0 if result > two_pi - 0.01 { result = 0.0; } result }