diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index db1d722..9287f2e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -5,8 +5,10 @@ pub mod add_shape; pub mod move_objects; +pub mod paint_bucket; pub mod transform_objects; pub use add_shape::AddShapeAction; pub use move_objects::MoveObjectsAction; +pub use paint_bucket::PaintBucketAction; pub use transform_objects::TransformObjectsAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs new file mode 100644 index 0000000..aa2eec0 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs @@ -0,0 +1,296 @@ +//! Paint bucket fill action +//! +//! This action performs a paint bucket fill operation starting from a click point, +//! using planar graph face detection to identify the region to fill. + +use crate::action::Action; +use crate::curve_segment::CurveSegment; +use crate::document::Document; +use crate::gap_handling::GapHandlingMode; +use crate::layer::AnyLayer; +use crate::object::Object; +use crate::planar_graph::PlanarGraph; +use crate::shape::ShapeColor; +use uuid::Uuid; +use vello::kurbo::Point; + +/// Action that performs a paint bucket fill operation +pub struct PaintBucketAction { + /// Layer ID to add the filled shape to + layer_id: Uuid, + + /// Click point where fill was initiated + click_point: Point, + + /// Fill color for the shape + fill_color: ShapeColor, + + /// Tolerance for gap bridging (in pixels) + tolerance: f64, + + /// Gap handling mode + gap_mode: GapHandlingMode, + + /// ID of the created shape (set after execution) + created_shape_id: Option, + + /// ID of the created object (set after execution) + created_object_id: Option, +} + +impl PaintBucketAction { + /// Create a new paint bucket action + /// + /// # Arguments + /// + /// * `layer_id` - The layer to add the filled shape to + /// * `click_point` - Point where the user clicked to initiate fill + /// * `fill_color` - Color to fill the region with + /// * `tolerance` - Gap tolerance in pixels (default: 2.0) + /// * `gap_mode` - Gap handling mode (SnapAndSplit or BridgeSegment) + pub fn new( + layer_id: Uuid, + click_point: Point, + fill_color: ShapeColor, + tolerance: f64, + gap_mode: GapHandlingMode, + ) -> Self { + Self { + layer_id, + click_point, + fill_color, + tolerance, + gap_mode, + created_shape_id: None, + created_object_id: None, + } + } +} + +impl Action for PaintBucketAction { + fn execute(&mut self, document: &mut Document) { + println!("=== PaintBucketAction::execute (Planar Graph Approach) ==="); + + // Step 1: Extract curves from stroked shapes only (not filled regions) + let all_curves = extract_curves_from_stroked_shapes(document, &self.layer_id); + + println!("Extracted {} curves from stroked shapes", all_curves.len()); + + if all_curves.is_empty() { + println!("No curves found, returning"); + return; + } + + // Step 2: Build planar graph + println!("Building planar graph..."); + let graph = PlanarGraph::build(&all_curves); + + // Step 3: Render debug visualization of planar graph + println!("Rendering planar graph debug visualization..."); + let (nodes_shape, edges_shape) = graph.render_debug(); + let nodes_object = Object::new(nodes_shape.id); + let edges_object = Object::new(edges_shape.id); + + if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) { + vector_layer.add_shape_internal(edges_shape); + vector_layer.add_object_internal(edges_object); + vector_layer.add_shape_internal(nodes_shape); + vector_layer.add_object_internal(nodes_object); + println!("DEBUG: Added graph visualization (yellow=edges, red=nodes)"); + } + + // Step 4: Find all faces + println!("Finding faces in planar graph..."); + let faces = graph.find_faces(); + + // Step 5: Find which face contains the click point + println!("Finding face containing click point {:?}...", self.click_point); + if let Some(face_idx) = graph.find_face_containing_point(self.click_point, &faces) { + println!("Found face {} containing click point!", face_idx); + + // Build the face boundary using actual curve segments + let face = &faces[face_idx]; + let face_path = graph.build_face_path(face); + + let face_shape = crate::shape::Shape::new(face_path) + .with_fill(self.fill_color); // Use the requested fill color + + let face_object = Object::new(face_shape.id); + + // Store the created IDs for rollback + self.created_shape_id = Some(face_shape.id); + self.created_object_id = Some(face_object.id); + + if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) { + vector_layer.add_shape_internal(face_shape); + vector_layer.add_object_internal(face_object); + println!("DEBUG: Added filled shape for face {}", face_idx); + } + } else { + println!("Click point is not inside any face!"); + } + + println!("=== Paint Bucket Complete: Face filled with curves ==="); + } + + fn rollback(&mut self, document: &mut Document) { + // Remove the created shape and object if they exist + if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_object_id) { + let layer = match document.get_layer_mut(&self.layer_id) { + Some(l) => l, + None => return, + }; + + if let AnyLayer::Vector(vector_layer) = layer { + vector_layer.remove_object_internal(&object_id); + vector_layer.remove_shape_internal(&shape_id); + } + + self.created_shape_id = None; + self.created_object_id = None; + } + } + + fn description(&self) -> String { + "Paint bucket fill".to_string() + } +} + +/// Extract curves from stroked shapes only (not filled regions) +/// +/// This filters out paint bucket filled shapes which have only fills, not strokes. +/// Stroked shapes define boundaries for the planar graph. +fn extract_curves_from_stroked_shapes( + document: &Document, + layer_id: &Uuid, +) -> Vec { + let mut all_curves = Vec::new(); + + // Get the specified layer + let layer = match document.get_layer(layer_id) { + Some(l) => l, + None => return all_curves, + }; + + // Extract curves only from this vector layer + if let AnyLayer::Vector(vector_layer) = layer { + // Extract curves from each object (which applies transforms to shapes) + for object in &vector_layer.objects { + // Find the shape for this object + let shape = match vector_layer.shapes.iter().find(|s| s.id == object.shape_id) { + Some(s) => s, + None => continue, + }; + + // Skip shapes without strokes (these are filled regions, not boundaries) + if shape.stroke_color.is_none() { + continue; + } + + // Get the transform matrix from the object + let transform_affine = object.transform.to_affine(); + + let path = shape.path(); + let mut current_point = Point::ZERO; + let mut segment_index = 0; + + for element in path.elements() { + // Extract curve segment from path element + if let Some(mut segment) = CurveSegment::from_path_element( + shape.id.as_u128() as usize, + segment_index, + element, + current_point, + ) { + // Apply the object's transform to all control points + for control_point in &mut segment.control_points { + *control_point = transform_affine * (*control_point); + } + + all_curves.push(segment); + segment_index += 1; + } + + // Update current point for next iteration (keep in local space) + match element { + vello::kurbo::PathEl::MoveTo(p) => current_point = *p, + vello::kurbo::PathEl::LineTo(p) => current_point = *p, + vello::kurbo::PathEl::QuadTo(_, p) => current_point = *p, + vello::kurbo::PathEl::CurveTo(_, _, p) => current_point = *p, + vello::kurbo::PathEl::ClosePath => {} + } + } + } + } + + all_curves +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::VectorLayer; + use vello::kurbo::{Rect, Shape as KurboShape}; + + #[test] + fn test_paint_bucket_action_basic() { + // Create a document with a vector layer + let mut document = Document::new("Test"); + let vector_layer = VectorLayer::new("Layer 1"); + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Create a simple rectangle shape (boundary for fill) + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let path = rect.to_path(0.1); + let shape = Shape::new(path); + let object = Object::new(shape.id); + + // Add the boundary shape + if let Some(AnyLayer::Vector(layer)) = document.get_layer_mut(&layer_id) { + layer.add_shape_internal(shape); + layer.add_object_internal(object); + } + + // Create and execute paint bucket action + let mut action = PaintBucketAction::new( + layer_id, + Point::new(50.0, 50.0), // Click in center + ShapeColor::rgb(255, 0, 0), // Red fill + 2.0, + GapHandlingMode::BridgeSegment, + ); + + action.execute(&mut document); + + // Verify a filled shape was created + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + // Should have original shape + filled shape + assert!(layer.shapes.len() >= 1); + assert!(layer.objects.len() >= 1); + } else { + panic!("Layer not found or not a vector layer"); + } + + // Test rollback + action.rollback(&mut document); + + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + // Should only have original shape + assert_eq!(layer.shapes.len(), 1); + assert_eq!(layer.objects.len(), 1); + } + } + + #[test] + fn test_paint_bucket_action_description() { + let action = PaintBucketAction::new( + Uuid::new_v4(), + Point::ZERO, + ShapeColor::rgb(0, 0, 255), + 2.0, + GapHandlingMode::BridgeSegment, + ); + + assert_eq!(action.description(), "Paint bucket fill"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/curve_intersection.rs b/lightningbeam-ui/lightningbeam-core/src/curve_intersection.rs new file mode 100644 index 0000000..b1d5174 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/curve_intersection.rs @@ -0,0 +1,337 @@ +//! Curve intersection algorithm using recursive subdivision +//! +//! This module implements intersection finding between Bezier curve segments +//! using a recursive subdivision algorithm similar to the one in bezier.js. +//! The algorithm is based on the paper "Intersection of Two Bezier Curves" +//! and uses bounding box tests to prune the search space. + +use crate::curve_segment::CurveSegment; +use vello::kurbo::Point; + +/// Result of a curve intersection test +#[derive(Debug, Clone)] +pub struct CurveIntersection { + /// Parameter t on the first curve (in range [0, 1]) + pub t1: f64, + /// Parameter t on the second curve (in range [0, 1]) + pub t2: f64, + /// Point of intersection + pub point: Point, +} + +/// Find all intersections between two curve segments +/// +/// Uses recursive subdivision with bounding box pruning. +/// The threshold determines when curves are considered "small enough" +/// to return an intersection point. +/// +/// # Parameters +/// - `curve1`: First curve segment +/// - `curve2`: Second curve segment +/// - `threshold`: Size threshold for convergence (sum of bbox widths + heights) +/// +/// # Returns +/// Vector of intersection points with parameters on both curves +pub fn find_intersections( + curve1: &CurveSegment, + curve2: &CurveSegment, + threshold: f64, +) -> Vec { + let mut results = Vec::new(); + pair_iteration(curve1, curve2, threshold, &mut results); + results +} + +/// Recursive subdivision algorithm for finding curve intersections +/// +/// This is the core algorithm that mirrors the JavaScript bezier.js implementation. +fn pair_iteration( + c1: &CurveSegment, + c2: &CurveSegment, + threshold: f64, + results: &mut Vec, +) { + // 1. Check if bounding boxes overlap - early exit if not + let bbox1 = c1.bounding_box(); + let bbox2 = c2.bounding_box(); + + if !bbox1.intersects(&bbox2) { + return; + } + + // 2. Base case: curves are small enough + let combined_size = bbox1.size() + bbox2.size(); + if combined_size < threshold { + // Found an intersection - compute the midpoint parameters + let t1_mid = (c1.t_start + c1.t_end) / 2.0; + let t2_mid = (c2.t_start + c2.t_end) / 2.0; + + // Evaluate at midpoints to get intersection point + // Average the two points for better accuracy + let p1 = c1.eval_at(0.5); + let p2 = c2.eval_at(0.5); + let point = Point::new((p1.x + p2.x) / 2.0, (p1.y + p2.y) / 2.0); + + results.push(CurveIntersection { + t1: t1_mid, + t2: t2_mid, + point, + }); + return; + } + + // 3. Recursive case: split both curves and test all 4 pairs + let (c1_left, c1_right) = c1.split_at(0.5); + let (c2_left, c2_right) = c2.split_at(0.5); + + // Test all 4 combinations: + // (c1_left, c2_left), (c1_left, c2_right), (c1_right, c2_left), (c1_right, c2_right) + pair_iteration(&c1_left, &c2_left, threshold, results); + pair_iteration(&c1_left, &c2_right, threshold, results); + pair_iteration(&c1_right, &c2_left, threshold, results); + pair_iteration(&c1_right, &c2_right, threshold, results); +} + +/// Find intersection between a curve and a line segment +/// +/// This is a specialized version for line-curve intersections which can be +/// more efficient than the general curve-curve intersection. +pub fn find_line_curve_intersections( + line: &CurveSegment, + curve: &CurveSegment, + threshold: f64, +) -> Vec { + // For now, just use the general algorithm + // TODO: Optimize with line-specific tests + find_intersections(line, curve, threshold) +} + +/// Check if two curves intersect (without computing exact intersection points) +/// +/// This is faster than find_intersections when you only need to know +/// whether curves intersect, not where. +pub fn curves_intersect(c1: &CurveSegment, c2: &CurveSegment, threshold: f64) -> bool { + curves_intersect_internal(c1, c2, threshold) +} + +fn curves_intersect_internal(c1: &CurveSegment, c2: &CurveSegment, threshold: f64) -> bool { + // Check if bounding boxes overlap + let bbox1 = c1.bounding_box(); + let bbox2 = c2.bounding_box(); + + if !bbox1.intersects(&bbox2) { + return false; + } + + // Base case: curves are small enough + let combined_size = bbox1.size() + bbox2.size(); + if combined_size < threshold { + return true; + } + + // Recursive case: split and test + let (c1_left, c1_right) = c1.split_at(0.5); + let (c2_left, c2_right) = c2.split_at(0.5); + + curves_intersect_internal(&c1_left, &c2_left, threshold) + || curves_intersect_internal(&c1_left, &c2_right, threshold) + || curves_intersect_internal(&c1_right, &c2_left, threshold) + || curves_intersect_internal(&c1_right, &c2_right, threshold) +} + +/// Remove duplicate intersections that are very close to each other +/// +/// The recursive subdivision algorithm can find the same intersection +/// multiple times from different branches. This function deduplicates +/// intersections that are within `epsilon` distance of each other. +pub fn deduplicate_intersections( + intersections: &[CurveIntersection], + epsilon: f64, +) -> Vec { + let mut unique = Vec::new(); + let epsilon_sq = epsilon * epsilon; + + for intersection in intersections { + // Check if this intersection is close to any existing one + let is_duplicate = unique.iter().any(|existing: &CurveIntersection| { + let dx = intersection.point.x - existing.point.x; + let dy = intersection.point.y - existing.point.y; + dx * dx + dy * dy < epsilon_sq + }); + + if !is_duplicate { + unique.push(intersection.clone()); + } + } + + unique +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::curve_segment::{CurveSegment, CurveType}; + + #[test] + fn test_line_line_intersection() { + // Two lines that cross at (50, 50) + let line1 = CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 100.0)], + ); + + let line2 = CurveSegment::new( + 1, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 100.0), Point::new(100.0, 0.0)], + ); + + let intersections = find_intersections(&line1, &line2, 1.0); + + assert!(!intersections.is_empty()); + + // Should find intersection near (50, 50) + let intersection = &intersections[0]; + assert!((intersection.point.x - 50.0).abs() < 5.0); + assert!((intersection.point.y - 50.0).abs() < 5.0); + } + + #[test] + fn test_parallel_lines_no_intersection() { + // Two parallel lines that don't intersect + let line1 = CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)], + ); + + let line2 = CurveSegment::new( + 1, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 10.0), Point::new(100.0, 10.0)], + ); + + let intersections = find_intersections(&line1, &line2, 1.0); + + assert!(intersections.is_empty()); + } + + #[test] + fn test_curves_intersect_check() { + // Two lines that cross + let line1 = CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 100.0)], + ); + + let line2 = CurveSegment::new( + 1, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 100.0), Point::new(100.0, 0.0)], + ); + + assert!(curves_intersect(&line1, &line2, 1.0)); + } + + #[test] + fn test_no_intersection_check() { + // Two lines that don't intersect + let line1 = CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0)], + ); + + let line2 = CurveSegment::new( + 1, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(20.0, 0.0), Point::new(30.0, 0.0)], + ); + + assert!(!curves_intersect(&line1, &line2, 1.0)); + } + + #[test] + fn test_deduplicate_intersections() { + let intersections = vec![ + CurveIntersection { + t1: 0.5, + t2: 0.5, + point: Point::new(50.0, 50.0), + }, + CurveIntersection { + t1: 0.50001, + t2: 0.50001, + point: Point::new(50.001, 50.001), + }, + CurveIntersection { + t1: 0.7, + t2: 0.3, + point: Point::new(70.0, 30.0), + }, + ]; + + let unique = deduplicate_intersections(&intersections, 0.1); + + // First two should be deduplicated, third should remain + assert_eq!(unique.len(), 2); + } + + #[test] + fn test_quadratic_curve_intersection() { + // Line from (0, 50) to (100, 50) + let line = CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 50.0), Point::new(100.0, 50.0)], + ); + + // Quadratic curve that crosses the line + let quad = CurveSegment::new( + 1, + 0, + CurveType::Quadratic, + 0.0, + 1.0, + vec![ + Point::new(50.0, 0.0), + Point::new(50.0, 100.0), + Point::new(50.0, 100.0), + ], + ); + + let intersections = find_intersections(&line, &quad, 1.0); + + // Should find at least one intersection + assert!(!intersections.is_empty()); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs b/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs new file mode 100644 index 0000000..6ec1174 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs @@ -0,0 +1,357 @@ +//! 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; + + // Minimum parameter range (if smaller, we've found an intersection) + const MIN_RANGE: f64 = 0.001; + + // 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; + } + + // If we've recursed deep enough or ranges are small enough, record intersection + if depth >= MAX_DEPTH || + ((t1_end - t1_start) < MIN_RANGE && (t2_end - t2_start) < MIN_RANGE) { + let t1 = (t1_start + t1_end) / 2.0; + let t2 = (t2_start + t2_end) / 2.0; + + intersections.push(Intersection { + t1, + t2: Some(t2), + point: orig_curve1.eval(t1), + }); + 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 intersection parameters using Newton's method +fn refine_intersection( + curve1: &CubicBez, + curve2: &CubicBez, + mut t1: f64, + mut t2: f64, +) -> (f64, f64) { + // Simple refinement: just find nearest points iteratively + for _ in 0..5 { + let p1 = curve1.eval(t1); + let nearest2 = curve2.nearest(p1, 1e-6); + t2 = nearest2.t; + + let p2 = curve2.eval(t2); + let nearest1 = curve1.nearest(p2, 1e-6); + t1 = nearest1.t; + } + + (t1.clamp(0.0, 1.0), t2.clamp(0.0, 1.0)) +} + +/// 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 within a tolerance +fn dedup_intersections(intersections: &mut Vec, tolerance: f64) { + let mut i = 0; + while i < intersections.len() { + let mut j = i + 1; + while j < intersections.len() { + let dist = (intersections[i].point - intersections[j].point).hypot(); + if dist < tolerance { + intersections.remove(j); + } else { + j += 1; + } + } + i += 1; + } +} + +#[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); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/curve_segment.rs b/lightningbeam-ui/lightningbeam-core/src/curve_segment.rs new file mode 100644 index 0000000..a29458e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/curve_segment.rs @@ -0,0 +1,501 @@ +//! Curve segment representation for paint bucket fill algorithm +//! +//! This module provides types for representing segments of Bezier curves +//! with parameter ranges. These segments are used to build filled paths +//! from the exact geometry of curves that bound a filled region. + +use vello::kurbo::{ + CubicBez, Line, ParamCurve, ParamCurveNearest, PathEl, Point, QuadBez, Shape, +}; + +/// Type of Bezier curve segment +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CurveType { + /// Straight line segment + Line, + /// Quadratic Bezier curve + Quadratic, + /// Cubic Bezier curve + Cubic, +} + +/// A segment of a Bezier curve with parameter range +/// +/// Represents a portion of a curve from parameter t_start to t_end. +/// The curve is identified by its index in a document's path list, +/// and the segment within that path. +#[derive(Debug, Clone)] +pub struct CurveSegment { + /// Index of the shape/path in the document + pub shape_index: usize, + /// Index of the segment within the path + pub segment_index: usize, + /// Type of curve + pub curve_type: CurveType, + /// Start parameter (0.0 to 1.0) + pub t_start: f64, + /// End parameter (0.0 to 1.0) + pub t_end: f64, + /// Cached control points for this segment + pub control_points: Vec, +} + +impl CurveSegment { + /// Create a new curve segment + pub fn new( + shape_index: usize, + segment_index: usize, + curve_type: CurveType, + t_start: f64, + t_end: f64, + control_points: Vec, + ) -> Self { + Self { + shape_index, + segment_index, + curve_type, + t_start, + t_end, + control_points, + } + } + + /// Create a curve segment from a full curve (t_start=0, t_end=1) + pub fn from_path_element( + shape_index: usize, + segment_index: usize, + element: &PathEl, + start_point: Point, + ) -> Option { + match element { + PathEl::LineTo(p) => Some(Self::new( + shape_index, + segment_index, + CurveType::Line, + 0.0, + 1.0, + vec![start_point, *p], + )), + PathEl::QuadTo(p1, p2) => Some(Self::new( + shape_index, + segment_index, + CurveType::Quadratic, + 0.0, + 1.0, + vec![start_point, *p1, *p2], + )), + PathEl::CurveTo(p1, p2, p3) => Some(Self::new( + shape_index, + segment_index, + CurveType::Cubic, + 0.0, + 1.0, + vec![start_point, *p1, *p2, *p3], + )), + PathEl::MoveTo(_) | PathEl::ClosePath => None, + } + } + + /// Evaluate the curve at parameter t (in segment's local [t_start, t_end] range) + pub fn eval_at(&self, t: f64) -> Point { + // Map t from segment range to curve range + let curve_t = self.t_start + t * (self.t_end - self.t_start); + + match self.curve_type { + CurveType::Line => { + let line = Line::new(self.control_points[0], self.control_points[1]); + line.eval(curve_t) + } + CurveType::Quadratic => { + let quad = QuadBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + ); + quad.eval(curve_t) + } + CurveType::Cubic => { + let cubic = CubicBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + self.control_points[3], + ); + cubic.eval(curve_t) + } + } + } + + /// Get the start point of this segment + pub fn start_point(&self) -> Point { + self.eval_at(0.0) + } + + /// Get the end point of this segment + pub fn end_point(&self) -> Point { + self.eval_at(1.0) + } + + /// Split this segment at parameter t (in local [0, 1] range) + /// + /// Returns (left_segment, right_segment) + pub fn split_at(&self, t: f64) -> (Self, Self) { + match self.curve_type { + CurveType::Line => { + let line = Line::new(self.control_points[0], self.control_points[1]); + let split_point = line.eval(t); + + let left = Self::new( + self.shape_index, + self.segment_index, + CurveType::Line, + 0.0, + 1.0, + vec![self.control_points[0], split_point], + ); + + let right = Self::new( + self.shape_index, + self.segment_index, + CurveType::Line, + 0.0, + 1.0, + vec![split_point, self.control_points[1]], + ); + + (left, right) + } + CurveType::Quadratic => { + let quad = QuadBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + ); + let (q1, q2) = quad.subdivide(); + + let left = Self::new( + self.shape_index, + self.segment_index, + CurveType::Quadratic, + 0.0, + 1.0, + vec![q1.p0, q1.p1, q1.p2], + ); + + let right = Self::new( + self.shape_index, + self.segment_index, + CurveType::Quadratic, + 0.0, + 1.0, + vec![q2.p0, q2.p1, q2.p2], + ); + + (left, right) + } + CurveType::Cubic => { + let cubic = CubicBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + self.control_points[3], + ); + let (c1, c2) = cubic.subdivide(); + + let left = Self::new( + self.shape_index, + self.segment_index, + CurveType::Cubic, + 0.0, + 1.0, + vec![c1.p0, c1.p1, c1.p2, c1.p3], + ); + + let right = Self::new( + self.shape_index, + self.segment_index, + CurveType::Cubic, + 0.0, + 1.0, + vec![c2.p0, c2.p1, c2.p2, c2.p3], + ); + + (left, right) + } + } + } + + /// Get the bounding box of this curve segment + pub fn bounding_box(&self) -> crate::quadtree::BoundingBox { + match self.curve_type { + CurveType::Line => { + let line = Line::new(self.control_points[0], self.control_points[1]); + let rect = line.bounding_box(); + crate::quadtree::BoundingBox::from_rect(rect) + } + CurveType::Quadratic => { + let quad = QuadBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + ); + let rect = quad.bounding_box(); + crate::quadtree::BoundingBox::from_rect(rect) + } + CurveType::Cubic => { + let cubic = CubicBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + self.control_points[3], + ); + let rect = cubic.bounding_box(); + crate::quadtree::BoundingBox::from_rect(rect) + } + } + } + + /// Get the nearest point on this curve to a given point + /// + /// Returns (parameter t, nearest point, distance squared) + pub fn nearest_point(&self, point: Point) -> (f64, Point, f64) { + match self.curve_type { + CurveType::Line => { + let line = Line::new(self.control_points[0], self.control_points[1]); + let t = line.nearest(point, 1e-6).t; + let nearest = line.eval(t); + let dist_sq = (nearest - point).hypot2(); + (t, nearest, dist_sq) + } + CurveType::Quadratic => { + let quad = QuadBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + ); + let t = quad.nearest(point, 1e-6).t; + let nearest = quad.eval(t); + let dist_sq = (nearest - point).hypot2(); + (t, nearest, dist_sq) + } + CurveType::Cubic => { + let cubic = CubicBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + self.control_points[3], + ); + let t = cubic.nearest(point, 1e-6).t; + let nearest = cubic.eval(t); + let dist_sq = (nearest - point).hypot2(); + (t, nearest, dist_sq) + } + } + } + + /// Convert this segment to a path element + pub fn to_path_element(&self) -> PathEl { + match self.curve_type { + CurveType::Line => PathEl::LineTo(self.control_points[1]), + CurveType::Quadratic => { + PathEl::QuadTo(self.control_points[1], self.control_points[2]) + } + CurveType::Cubic => PathEl::CurveTo( + self.control_points[1], + self.control_points[2], + self.control_points[3], + ), + } + } + + /// Convert this segment to a cubic Bezier curve + /// + /// Lines and quadratic curves are converted to their cubic equivalents. + pub fn to_cubic_bez(&self) -> CubicBez { + match self.curve_type { + CurveType::Line => { + // Convert line to cubic: p0, p0 + 1/3(p1-p0), p0 + 2/3(p1-p0), p1 + let p0 = self.control_points[0]; + let p1 = self.control_points[1]; + let c1 = Point::new( + p0.x + (p1.x - p0.x) / 3.0, + p0.y + (p1.y - p0.y) / 3.0, + ); + let c2 = Point::new( + p0.x + 2.0 * (p1.x - p0.x) / 3.0, + p0.y + 2.0 * (p1.y - p0.y) / 3.0, + ); + CubicBez::new(p0, c1, c2, p1) + } + CurveType::Quadratic => { + // Convert quadratic to cubic using standard formula + // Cubic control points: p0, p0 + 2/3(p1-p0), p2 + 2/3(p1-p2), p2 + let p0 = self.control_points[0]; + let p1 = self.control_points[1]; + let p2 = self.control_points[2]; + let c1 = Point::new( + p0.x + 2.0 * (p1.x - p0.x) / 3.0, + p0.y + 2.0 * (p1.y - p0.y) / 3.0, + ); + let c2 = Point::new( + p2.x + 2.0 * (p1.x - p2.x) / 3.0, + p2.y + 2.0 * (p1.y - p2.y) / 3.0, + ); + CubicBez::new(p0, c1, c2, p2) + } + CurveType::Cubic => { + // Already cubic, just create from control points + CubicBez::new( + self.control_points[0], + self.control_points[1], + self.control_points[2], + self.control_points[3], + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_line_segment_creation() { + let seg = CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 100.0)], + ); + + assert_eq!(seg.curve_type, CurveType::Line); + assert_eq!(seg.start_point(), Point::new(0.0, 0.0)); + assert_eq!(seg.end_point(), Point::new(100.0, 100.0)); + } + + #[test] + fn test_line_segment_eval() { + let seg = CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 100.0)], + ); + + let mid = seg.eval_at(0.5); + assert!((mid.x - 50.0).abs() < 1e-6); + assert!((mid.y - 50.0).abs() < 1e-6); + } + + #[test] + fn test_line_segment_split() { + let seg = CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 100.0)], + ); + + let (left, right) = seg.split_at(0.5); + + // After splitting, both segments have full parameter range + assert_eq!(left.t_start, 0.0); + assert_eq!(left.t_end, 1.0); + assert_eq!(right.t_start, 0.0); + assert_eq!(right.t_end, 1.0); + + // End of left should match start of right + assert_eq!(left.end_point(), right.start_point()); + + // Check that split happened at the midpoint + let expected_mid = Point::new(50.0, 50.0); + assert!((left.end_point().x - expected_mid.x).abs() < 1e-6); + assert!((left.end_point().y - expected_mid.y).abs() < 1e-6); + } + + #[test] + fn test_quadratic_segment_creation() { + let seg = CurveSegment::new( + 0, + 0, + CurveType::Quadratic, + 0.0, + 1.0, + vec![ + Point::new(0.0, 0.0), + Point::new(50.0, 100.0), + Point::new(100.0, 0.0), + ], + ); + + assert_eq!(seg.curve_type, CurveType::Quadratic); + assert_eq!(seg.control_points.len(), 3); + } + + #[test] + fn test_cubic_segment_creation() { + let seg = CurveSegment::new( + 0, + 0, + CurveType::Cubic, + 0.0, + 1.0, + vec![ + Point::new(0.0, 0.0), + Point::new(33.0, 100.0), + Point::new(66.0, 100.0), + Point::new(100.0, 0.0), + ], + ); + + assert_eq!(seg.curve_type, CurveType::Cubic); + assert_eq!(seg.control_points.len(), 4); + } + + #[test] + fn test_from_path_element_line() { + let start = Point::new(0.0, 0.0); + let end = Point::new(100.0, 100.0); + let element = PathEl::LineTo(end); + + let seg = CurveSegment::from_path_element(0, 0, &element, start).unwrap(); + + assert_eq!(seg.curve_type, CurveType::Line); + assert_eq!(seg.control_points.len(), 2); + assert_eq!(seg.start_point(), start); + assert_eq!(seg.end_point(), end); + } + + #[test] + fn test_from_path_element_quad() { + let start = Point::new(0.0, 0.0); + let element = PathEl::QuadTo(Point::new(50.0, 100.0), Point::new(100.0, 0.0)); + + let seg = CurveSegment::from_path_element(0, 0, &element, start).unwrap(); + + assert_eq!(seg.curve_type, CurveType::Quadratic); + assert_eq!(seg.control_points.len(), 3); + } + + #[test] + fn test_from_path_element_cubic() { + let start = Point::new(0.0, 0.0); + let element = PathEl::CurveTo( + Point::new(33.0, 100.0), + Point::new(66.0, 100.0), + Point::new(100.0, 0.0), + ); + + let seg = CurveSegment::from_path_element(0, 0, &element, start).unwrap(); + + assert_eq!(seg.curve_type, CurveType::Cubic); + assert_eq!(seg.control_points.len(), 4); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs b/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs new file mode 100644 index 0000000..023cea2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/flood_fill.rs @@ -0,0 +1,352 @@ +//! Flood fill algorithm for paint bucket tool +//! +//! This module implements a flood fill that tracks which curves each point +//! touches. Instead of filling with pixels, it returns boundary points that +//! can be used to construct a filled shape from exact curve geometry. + +use crate::curve_segment::CurveSegment; +use crate::quadtree::{BoundingBox, Quadtree}; +use std::collections::{HashSet, VecDeque}; +use vello::kurbo::Point; + +/// A point on the boundary of the filled region +#[derive(Debug, Clone)] +pub struct BoundaryPoint { + /// The sampled point location + pub point: Point, + /// Index of the nearest curve segment + pub curve_index: usize, + /// Parameter t on the nearest curve (0.0 to 1.0) + pub t: f64, + /// Nearest point on the curve + pub nearest_point: Point, + /// Distance to the nearest curve + pub distance: f64, +} + +/// Result of a flood fill operation +#[derive(Debug)] +pub struct FloodFillResult { + /// All boundary points found during flood fill + pub boundary_points: Vec, + /// All interior points that were filled + pub interior_points: Vec, +} + +/// Flood fill configuration +pub struct FloodFillConfig { + /// Distance threshold - points closer than this to a curve are boundary points + pub epsilon: f64, + /// Step size for sampling (distance between sampled points) + pub step_size: f64, + /// Maximum number of points to sample (prevents infinite loops) + pub max_points: usize, + /// Bounding box to constrain the fill + pub bounds: Option, +} + +impl Default for FloodFillConfig { + fn default() -> Self { + Self { + epsilon: 2.0, + step_size: 5.0, + max_points: 10000, + bounds: None, + } + } +} + +/// Perform flood fill starting from a point +/// +/// This function expands outward from the start point, stopping when it +/// encounters curves (within epsilon distance). It returns all boundary +/// points along with information about which curve each point is near. +/// +/// # Parameters +/// - `start`: Starting point for the flood fill +/// - `curves`: All curve segments in the scene +/// - `quadtree`: Spatial index for efficient curve queries +/// - `config`: Flood fill configuration +/// +/// # Returns +/// FloodFillResult with boundary and interior points +pub fn flood_fill( + start: Point, + curves: &[CurveSegment], + quadtree: &Quadtree, + config: &FloodFillConfig, +) -> FloodFillResult { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + let mut boundary_points = Vec::new(); + let mut interior_points = Vec::new(); + + // Quantize start point to grid + let start_grid = point_to_grid(start, config.step_size); + queue.push_back(start_grid); + visited.insert(start_grid); + + while let Some(grid_point) = queue.pop_front() { + // Check max points limit + if visited.len() >= config.max_points { + break; + } + + // Convert grid point back to actual coordinates + let point = grid_to_point(grid_point, config.step_size); + + // Check bounds if specified + if let Some(ref bounds) = config.bounds { + if !bounds.contains_point(point) { + continue; + } + } + + // Query quadtree for nearby curves + let query_bbox = BoundingBox::around_point(point, config.epsilon * 2.0); + let nearby_curve_indices = quadtree.query(&query_bbox); + + // Find the nearest curve + let nearest = find_nearest_curve(point, curves, &nearby_curve_indices); + + if let Some((curve_idx, t, nearest_point, distance)) = nearest { + // If we're within epsilon, this is a boundary point + if distance < config.epsilon { + boundary_points.push(BoundaryPoint { + point, + curve_index: curve_idx, + t, + nearest_point, + distance, + }); + continue; // Don't expand from boundary points + } + } + + // This is an interior point - add to interior and expand + interior_points.push(point); + + // Add neighbors to queue (4-directional) + let neighbors = [ + (grid_point.0 + 1, grid_point.1), // Right + (grid_point.0 - 1, grid_point.1), // Left + (grid_point.0, grid_point.1 + 1), // Down + (grid_point.0, grid_point.1 - 1), // Up + (grid_point.0 + 1, grid_point.1 + 1), // Diagonal: down-right + (grid_point.0 + 1, grid_point.1 - 1), // Diagonal: up-right + (grid_point.0 - 1, grid_point.1 + 1), // Diagonal: down-left + (grid_point.0 - 1, grid_point.1 - 1), // Diagonal: up-left + ]; + + for neighbor in neighbors { + if !visited.contains(&neighbor) { + visited.insert(neighbor); + queue.push_back(neighbor); + } + } + } + + FloodFillResult { + boundary_points, + interior_points, + } +} + +/// Convert a point to grid coordinates +fn point_to_grid(point: Point, step_size: f64) -> (i32, i32) { + let x = (point.x / step_size).round() as i32; + let y = (point.y / step_size).round() as i32; + (x, y) +} + +/// Convert grid coordinates back to a point +fn grid_to_point(grid: (i32, i32), step_size: f64) -> Point { + Point::new(grid.0 as f64 * step_size, grid.1 as f64 * step_size) +} + +/// Find the nearest curve to a point from a set of candidate curves +/// +/// Returns (curve_index, parameter_t, nearest_point, distance) +fn find_nearest_curve( + point: Point, + all_curves: &[CurveSegment], + candidate_indices: &[usize], +) -> Option<(usize, f64, Point, f64)> { + let mut best: Option<(usize, f64, Point, f64)> = None; + + for &curve_idx in candidate_indices { + if curve_idx >= all_curves.len() { + continue; + } + + let curve = &all_curves[curve_idx]; + let (t, nearest_point, dist_sq) = curve.nearest_point(point); + let distance = dist_sq.sqrt(); + + match best { + None => { + best = Some((curve_idx, t, nearest_point, distance)); + } + Some((_, _, _, best_dist)) if distance < best_dist => { + best = Some((curve_idx, t, nearest_point, distance)); + } + _ => {} + } + } + + best +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::curve_segment::{CurveSegment, CurveType}; + + #[test] + fn test_point_to_grid_conversion() { + let point = Point::new(10.0, 20.0); + let step_size = 5.0; + + let grid = point_to_grid(point, step_size); + assert_eq!(grid, (2, 4)); + + let back = grid_to_point(grid, step_size); + assert!((back.x - 10.0).abs() < 0.1); + assert!((back.y - 20.0).abs() < 0.1); + } + + #[test] + fn test_find_nearest_curve() { + let curves = vec![ + CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)], + ), + CurveSegment::new( + 1, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 50.0), Point::new(100.0, 50.0)], + ), + ]; + + let point = Point::new(50.0, 10.0); + let candidates = vec![0, 1]; + + let result = find_nearest_curve(point, &curves, &candidates); + assert!(result.is_some()); + + let (curve_idx, _t, _nearest, distance) = result.unwrap(); + assert_eq!(curve_idx, 0); // Should be nearest to first curve + assert!((distance - 10.0).abs() < 1.0); + } + + #[test] + fn test_flood_fill_simple_box() { + // Create a simple box with 4 lines + let curves = vec![ + // Bottom + CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)], + ), + // Right + CurveSegment::new( + 1, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(100.0, 0.0), Point::new(100.0, 100.0)], + ), + // Top + CurveSegment::new( + 2, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(100.0, 100.0), Point::new(0.0, 100.0)], + ), + // Left + CurveSegment::new( + 3, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 100.0), Point::new(0.0, 0.0)], + ), + ]; + + // Build quadtree + let mut quadtree = Quadtree::new(BoundingBox::new(-10.0, 110.0, -10.0, 110.0), 4); + for (i, curve) in curves.iter().enumerate() { + let bbox = curve.bounding_box(); + quadtree.insert(&bbox, i); + } + + // Fill from center + let config = FloodFillConfig { + epsilon: 2.0, + step_size: 5.0, + max_points: 10000, + bounds: Some(BoundingBox::new(-10.0, 110.0, -10.0, 110.0)), + }; + + let result = flood_fill(Point::new(50.0, 50.0), &curves, &quadtree, &config); + + // Should have boundary points + assert!(!result.boundary_points.is_empty()); + // Should have interior points + assert!(!result.interior_points.is_empty()); + + // All boundary points should be within epsilon of a curve + for bp in &result.boundary_points { + assert!(bp.distance < config.epsilon); + } + } + + #[test] + fn test_flood_fill_respects_bounds() { + let curves = vec![CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)], + )]; + + let mut quadtree = Quadtree::new(BoundingBox::new(-10.0, 110.0, -10.0, 110.0), 4); + for (i, curve) in curves.iter().enumerate() { + let bbox = curve.bounding_box(); + quadtree.insert(&bbox, i); + } + + let config = FloodFillConfig { + epsilon: 2.0, + step_size: 5.0, + max_points: 1000, + bounds: Some(BoundingBox::new(0.0, 50.0, 0.0, 50.0)), + }; + + let result = flood_fill(Point::new(25.0, 25.0), &curves, &quadtree, &config); + + // All points should be within bounds + for point in &result.interior_points { + assert!(point.x >= 0.0 && point.x <= 50.0); + assert!(point.y >= 0.0 && point.y <= 50.0); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/gap_handling.rs b/lightningbeam-ui/lightningbeam-core/src/gap_handling.rs new file mode 100644 index 0000000..cdeaabd --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/gap_handling.rs @@ -0,0 +1,85 @@ +//! Gap handling modes for paint bucket fill +//! +//! When curves don't precisely intersect but come within tolerance distance, +//! we need to decide how to bridge the gap. This module defines the available +//! strategies. + +/// Mode for handling gaps between curves during paint bucket fill +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GapHandlingMode { + /// Modify curves to connect at the midpoint of closest approach + /// + /// When two curves come within tolerance distance but don't exactly intersect, + /// this mode will: + /// 1. Find the closest approach point between the curves + /// 2. Calculate the midpoint between the two closest points + /// 3. Split both curves at their respective t parameters + /// 4. Snap the endpoints to the midpoint + /// + /// This creates a precise intersection by modifying the curve geometry. + /// The modification is temporary (only for the fill operation) and doesn't + /// affect the original shapes. + SnapAndSplit, + + /// Insert a line segment to bridge the gap + /// + /// When two curves come within tolerance distance but don't exactly intersect, + /// this mode will: + /// 1. Find the closest approach point between the curves + /// 2. Insert a straight line segment from the end of one curve to the start of the next + /// + /// This preserves the original curve geometry but adds artificial connecting segments. + /// Bridge segments are included in the final filled path. + BridgeSegment, +} + +impl Default for GapHandlingMode { + fn default() -> Self { + // Default to bridge segments as it's less invasive + GapHandlingMode::BridgeSegment + } +} + +impl GapHandlingMode { + /// Get a human-readable description of this mode + pub fn description(&self) -> &'static str { + match self { + GapHandlingMode::SnapAndSplit => { + "Snap curves to midpoint and split at intersection" + } + GapHandlingMode::BridgeSegment => { + "Insert line segments to bridge gaps between curves" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_mode() { + assert_eq!(GapHandlingMode::default(), GapHandlingMode::BridgeSegment); + } + + #[test] + fn test_description() { + let snap = GapHandlingMode::SnapAndSplit; + let bridge = GapHandlingMode::BridgeSegment; + + assert!(!snap.description().is_empty()); + assert!(!bridge.description().is_empty()); + assert_ne!(snap.description(), bridge.description()); + } + + #[test] + fn test_equality() { + let mode1 = GapHandlingMode::SnapAndSplit; + let mode2 = GapHandlingMode::SnapAndSplit; + let mode3 = GapHandlingMode::BridgeSegment; + + assert_eq!(mode1, mode2); + assert_ne!(mode1, mode3); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/intersection_graph.rs b/lightningbeam-ui/lightningbeam-core/src/intersection_graph.rs new file mode 100644 index 0000000..ff81d06 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/intersection_graph.rs @@ -0,0 +1,655 @@ +//! 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() { + let seg1 = VisitedSegment::new(0, 0.123, 0.456); + let seg2 = VisitedSegment::new(0, 0.124, 0.457); + let seg3 = VisitedSegment::new(0, 0.123, 0.456); + + assert_ne!(seg1, seg2); + assert_eq!(seg1, seg3); + } + + #[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() { + let curves = vec![ + CurveSegment::new( + 0, + 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 +} diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index dacd2f5..f55a73c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -16,3 +16,13 @@ pub mod action; pub mod actions; pub mod selection; pub mod hit_test; +pub mod quadtree; +pub mod tolerance_quadtree; +pub mod curve_segment; +pub mod curve_intersection; +pub mod curve_intersections; +pub mod flood_fill; +pub mod gap_handling; +pub mod intersection_graph; +pub mod segment_builder; +pub mod planar_graph; diff --git a/lightningbeam-ui/lightningbeam-core/src/planar_graph.rs b/lightningbeam-ui/lightningbeam-core/src/planar_graph.rs new file mode 100644 index 0000000..6c97b6d --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/planar_graph.rs @@ -0,0 +1,541 @@ +//! Planar graph construction for paint bucket fill +//! +//! This module builds a planar graph from a collection of curves by: +//! 1. Finding all intersections (curve-curve and self-intersections) +//! 2. Splitting curves at intersection points to create graph edges +//! 3. Creating nodes at all intersection points and curve endpoints +//! 4. Connecting edges to form a complete planar graph +//! +//! The resulting graph can be used for face detection to identify regions for filling. + +use crate::curve_intersections::{find_curve_intersections, find_self_intersections}; +use crate::curve_segment::CurveSegment; +use crate::shape::{Shape, ShapeColor, StrokeStyle}; +use std::collections::{HashMap, HashSet}; +use vello::kurbo::{BezPath, Circle, CubicBez, Point, Shape as KurboShape}; + +/// A node in the planar graph (intersection point or endpoint) +#[derive(Debug, Clone)] +pub struct GraphNode { + /// Position of the node + pub position: Point, + /// Indices of edges connected to this node + pub edge_indices: Vec, +} + +impl GraphNode { + pub fn new(position: Point) -> Self { + Self { + position, + edge_indices: Vec::new(), + } + } +} + +/// An edge in the planar graph (curve segment between two nodes) +#[derive(Debug, Clone)] +pub struct GraphEdge { + /// Index of start node + pub start_node: usize, + /// Index of end node + pub end_node: usize, + /// Original curve ID + pub curve_id: usize, + /// Parameter at start of this edge on the original curve [0, 1] + pub t_start: f64, + /// Parameter at end of this edge on the original curve [0, 1] + pub t_end: f64, +} + +impl GraphEdge { + pub fn new( + start_node: usize, + end_node: usize, + curve_id: usize, + t_start: f64, + t_end: f64, + ) -> Self { + Self { + start_node, + end_node, + curve_id, + t_start, + t_end, + } + } +} + +/// Planar graph structure +pub struct PlanarGraph { + /// All nodes in the graph + pub nodes: Vec, + /// All edges in the graph + pub edges: Vec, + /// Original curves (referenced by edges) + pub curves: Vec, +} + +impl PlanarGraph { + /// Build a planar graph from a collection of curve segments + /// + /// # Arguments + /// + /// * `curve_segments` - The input curve segments + /// + /// # Returns + /// + /// A complete planar graph with nodes at all intersections and edges connecting them + pub fn build(curve_segments: &[CurveSegment]) -> Self { + println!("PlanarGraph::build started with {} curves", curve_segments.len()); + + // Convert curve segments to cubic beziers + let curves: Vec = curve_segments + .iter() + .map(|seg| seg.to_cubic_bez()) + .collect(); + + // Find all intersection points + let intersections = Self::find_all_intersections(&curves); + println!("Found {} intersection points", intersections.len()); + + // Create nodes and edges + let (nodes, edges) = Self::build_nodes_and_edges(&curves, intersections); + println!("Created {} nodes and {} edges", nodes.len(), edges.len()); + + Self { + nodes, + edges, + curves, + } + } + + /// Find all intersections between curves + /// + /// Returns a map from curve_id to sorted list of (t_value, point) intersections + fn find_all_intersections(curves: &[CubicBez]) -> HashMap> { + let mut intersections: HashMap> = HashMap::new(); + + // Initialize with endpoints for all curves + for (i, curve) in curves.iter().enumerate() { + let mut curve_intersections = vec![ + (0.0, curve.p0), + (1.0, curve.p3), + ]; + intersections.insert(i, curve_intersections); + } + + // Find curve-curve intersections + for i in 0..curves.len() { + for j in (i + 1)..curves.len() { + let curve_i_intersections = find_curve_intersections(&curves[i], &curves[j]); + + for intersection in curve_i_intersections { + // Add to curve i + intersections + .get_mut(&i) + .unwrap() + .push((intersection.t1, intersection.point)); + + // Add to curve j + if let Some(t2) = intersection.t2 { + intersections + .get_mut(&j) + .unwrap() + .push((t2, intersection.point)); + } + } + } + + // Find self-intersections + let self_intersections = find_self_intersections(&curves[i]); + for intersection in self_intersections { + intersections + .get_mut(&i) + .unwrap() + .push((intersection.t1, intersection.point)); + if let Some(t2) = intersection.t2 { + intersections + .get_mut(&i) + .unwrap() + .push((t2, intersection.point)); + } + } + } + + // Sort and deduplicate intersections for each curve + for curve_intersections in intersections.values_mut() { + curve_intersections.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + // Remove duplicates (points very close together) + let mut i = 0; + while i + 1 < curve_intersections.len() { + let dist = (curve_intersections[i].1 - curve_intersections[i + 1].1).hypot(); + if dist < 0.5 { + curve_intersections.remove(i + 1); + } else { + i += 1; + } + } + } + + intersections + } + + /// Build nodes and edges from curves and their intersections + fn build_nodes_and_edges( + curves: &[CubicBez], + intersections: HashMap>, + ) -> (Vec, Vec) { + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + + // Map from position to node index (to avoid duplicate nodes) + let mut position_to_node: HashMap<(i32, i32), usize> = HashMap::new(); + + // Helper to get or create node at a position + let mut get_or_create_node = |position: Point, + nodes: &mut Vec, + position_to_node: &mut HashMap<(i32, i32), usize>| + -> usize { + // Round to nearest pixel for lookup + let key = (position.x.round() as i32, position.y.round() as i32); + + if let Some(&node_idx) = position_to_node.get(&key) { + node_idx + } else { + let node_idx = nodes.len(); + nodes.push(GraphNode::new(position)); + position_to_node.insert(key, node_idx); + node_idx + } + }; + + // Create edges for each curve + for (curve_id, curve_intersections) in intersections.iter() { + // Create edges between consecutive intersection points + for i in 0..(curve_intersections.len() - 1) { + let (t_start, p_start) = curve_intersections[i]; + let (t_end, p_end) = curve_intersections[i + 1]; + + // Get or create nodes + let start_node = get_or_create_node(p_start, &mut nodes, &mut position_to_node); + let end_node = get_or_create_node(p_end, &mut nodes, &mut position_to_node); + + // Create edge + let edge_idx = edges.len(); + edges.push(GraphEdge::new( + start_node, + end_node, + *curve_id, + t_start, + t_end, + )); + + // Add edge to nodes + nodes[start_node].edge_indices.push(edge_idx); + nodes[end_node].edge_indices.push(edge_idx); + } + } + + (nodes, edges) + } + + /// Render debug visualization of the planar graph + /// + /// Returns two shapes: one for nodes (red circles) and one for edges (yellow lines) + pub fn render_debug(&self) -> (Shape, Shape) { + // Render nodes as red circles + let mut nodes_path = BezPath::new(); + for node in &self.nodes { + let circle = Circle::new(node.position, 3.0); + nodes_path.extend(circle.to_path(0.1)); + } + let nodes_shape = Shape::new(nodes_path).with_stroke( + ShapeColor::rgb(255, 0, 0), + StrokeStyle { + width: 1.0, + ..Default::default() + }, + ); + + // Render edges as yellow straight lines + let mut edges_path = BezPath::new(); + for edge in &self.edges { + let start_pos = self.nodes[edge.start_node].position; + let end_pos = self.nodes[edge.end_node].position; + edges_path.move_to(start_pos); + edges_path.line_to(end_pos); + } + let edges_shape = Shape::new(edges_path).with_stroke( + ShapeColor::rgb(255, 255, 0), + StrokeStyle { + width: 0.5, + ..Default::default() + }, + ); + + (nodes_shape, edges_shape) + } + + /// Find all faces in the planar graph + pub fn find_faces(&self) -> Vec { + // Debug: Print graph structure + println!("\n=== GRAPH STRUCTURE DEBUG ==="); + for (node_idx, node) in self.nodes.iter().enumerate() { + println!("Node {}: pos=({:.1}, {:.1}), edges={:?}", + node_idx, node.position.x, node.position.y, node.edge_indices); + } + for (edge_idx, edge) in self.edges.iter().enumerate() { + println!("Edge {}: {} -> {}", edge_idx, edge.start_node, edge.end_node); + } + println!("=== END GRAPH STRUCTURE ===\n"); + + let mut faces = Vec::new(); + let mut used_half_edges = HashSet::new(); + + // Try starting from each edge in both directions + for edge_idx in 0..self.edges.len() { + // Try forward direction + if !used_half_edges.contains(&(edge_idx, true)) { + if let Some(face) = self.trace_face(edge_idx, true, &mut used_half_edges) { + faces.push(face); + } + } + + // Try backward direction + if !used_half_edges.contains(&(edge_idx, false)) { + if let Some(face) = self.trace_face(edge_idx, false, &mut used_half_edges) { + faces.push(face); + } + } + } + + println!("Found {} faces", faces.len()); + faces + } + + /// Trace a face starting from an edge in a given direction + /// Returns None if the face is already traced or invalid + fn trace_face( + &self, + start_edge: usize, + forward: bool, + used_half_edges: &mut HashSet<(usize, bool)>, + ) -> Option { + let mut edge_sequence = Vec::new(); + let mut current_edge = start_edge; + let mut current_forward = forward; + + loop { + // Mark this half-edge as used + if used_half_edges.contains(&(current_edge, current_forward)) { + // Already traced this half-edge + return None; + } + + edge_sequence.push((current_edge, current_forward)); + used_half_edges.insert((current_edge, current_forward)); + + // Get the end node of this half-edge + let edge = &self.edges[current_edge]; + let end_node = if current_forward { + edge.end_node + } else { + edge.start_node + }; + + // Find the next edge in counterclockwise order around end_node + let next = self.find_next_ccw_edge(current_edge, current_forward, end_node); + + if let Some((next_edge, next_forward)) = next { + current_edge = next_edge; + current_forward = next_forward; + + // Check if we've completed the loop + if current_edge == start_edge && current_forward == forward { + return Some(Face { edges: edge_sequence }); + } + } else { + // Dead end - not a valid face + return None; + } + + // Safety check to prevent infinite loops + if edge_sequence.len() > self.edges.len() * 2 { + println!("Warning: Potential infinite loop detected in face tracing"); + return None; + } + } + } + + /// Find the next edge in counterclockwise order around a node + fn find_next_ccw_edge( + &self, + incoming_edge: usize, + incoming_forward: bool, + node_idx: usize, + ) -> Option<(usize, bool)> { + let node = &self.nodes[node_idx]; + + // Get the incoming direction vector (pointing INTO this node) + let edge = &self.edges[incoming_edge]; + let incoming_dir = if incoming_forward { + let start_pos = self.nodes[edge.start_node].position; + let end_pos = self.nodes[edge.end_node].position; + (end_pos.x - start_pos.x, end_pos.y - start_pos.y) + } else { + let start_pos = self.nodes[edge.start_node].position; + let end_pos = self.nodes[edge.end_node].position; + (start_pos.x - end_pos.x, start_pos.y - end_pos.y) + }; + + // Find all outgoing edges from this node + let mut candidates = Vec::new(); + for &edge_idx in &node.edge_indices { + let edge = &self.edges[edge_idx]; + + // Check if this edge goes out from node_idx + if edge.start_node == node_idx { + // Forward direction + let end_pos = self.nodes[edge.end_node].position; + let node_pos = node.position; + let out_dir = (end_pos.x - node_pos.x, end_pos.y - node_pos.y); + candidates.push((edge_idx, true, out_dir)); + } + + if edge.end_node == node_idx { + // Backward direction + let start_pos = self.nodes[edge.start_node].position; + let node_pos = node.position; + let out_dir = (start_pos.x - node_pos.x, start_pos.y - node_pos.y); + candidates.push((edge_idx, false, out_dir)); + } + } + + println!("find_next_ccw_edge: node {} has {} candidates", node_idx, candidates.len()); + + // Find the edge that makes the smallest left turn (most counterclockwise) + let mut best_edge = None; + let mut best_angle = std::f64::MAX; + + for (edge_idx, forward, out_dir) in candidates { + // Skip the edge we came from (in opposite direction) + if edge_idx == incoming_edge && forward == !incoming_forward { + println!(" Skipping edge {} (came from there)", edge_idx); + continue; + } + + // Compute angle from incoming to outgoing (counterclockwise) + let angle = angle_between_ccw(incoming_dir, out_dir); + println!(" Edge {} dir={} angle={}", edge_idx, if forward { "fwd" } else { "bwd" }, angle); + + if angle < best_angle { + best_angle = angle; + best_edge = Some((edge_idx, forward)); + } + } + + println!(" Best: {:?} angle={}", best_edge, best_angle); + best_edge + } + + /// Find which face contains a given point + pub fn find_face_containing_point(&self, point: Point, faces: &[Face]) -> Option { + for (i, face) in faces.iter().enumerate() { + if self.point_in_face(point, face) { + return Some(i); + } + } + None + } + + /// Test if a point is inside a face using ray casting + fn point_in_face(&self, point: Point, face: &Face) -> bool { + // Build polygon from face edges + let mut polygon_points = Vec::new(); + + for &(edge_idx, forward) in &face.edges { + let edge = &self.edges[edge_idx]; + let node_idx = if forward { edge.start_node } else { edge.end_node }; + polygon_points.push(self.nodes[node_idx].position); + } + + // Ray casting algorithm + point_in_polygon(point, &polygon_points) + } + + /// Build a BezPath from a face using the actual curve segments + pub fn build_face_path(&self, face: &Face) -> BezPath { + use vello::kurbo::ParamCurve; + + let mut path = BezPath::new(); + let mut first = true; + + for &(edge_idx, forward) in &face.edges { + let edge = &self.edges[edge_idx]; + let orig_curve = &self.curves[edge.curve_id]; + + // Get the curve segment for this edge + let segment = if forward { + orig_curve.subsegment(edge.t_start..edge.t_end) + } else { + // Reverse the segment + orig_curve.subsegment(edge.t_end..edge.t_start) + }; + + if first { + path.move_to(segment.p0); + first = false; + } + + // Add the curve segment + path.curve_to(segment.p1, segment.p2, segment.p3); + } + + path.close_path(); + path + } +} + +/// A face in the planar graph (bounded region) +#[derive(Debug, Clone)] +pub struct Face { + /// Sequence of (edge_index, is_forward) pairs that form the boundary + pub edges: Vec<(usize, bool)>, +} + +/// Compute the counterclockwise angle from v1 to v2 +fn angle_between_ccw(v1: (f64, f64), v2: (f64, f64)) -> f64 { + let angle1 = v1.1.atan2(v1.0); + let angle2 = v2.1.atan2(v2.0); + let mut diff = angle2 - angle1; + + // Normalize to [0, 2π) + while diff < 0.0 { + diff += 2.0 * std::f64::consts::PI; + } + while diff >= 2.0 * std::f64::consts::PI { + diff -= 2.0 * std::f64::consts::PI; + } + + diff +} + +/// Test if a point is inside a polygon using ray casting +fn point_in_polygon(point: Point, polygon: &[Point]) -> bool { + let mut inside = false; + let n = polygon.len(); + + for i in 0..n { + let j = (i + 1) % n; + let pi = polygon[i]; + let pj = polygon[j]; + + if ((pi.y > point.y) != (pj.y > point.y)) && + (point.x < (pj.x - pi.x) * (point.y - pi.y) / (pj.y - pi.y) + pi.x) { + inside = !inside; + } + } + + inside +} diff --git a/lightningbeam-ui/lightningbeam-core/src/quadtree.rs b/lightningbeam-ui/lightningbeam-core/src/quadtree.rs new file mode 100644 index 0000000..8017676 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/quadtree.rs @@ -0,0 +1,533 @@ +//! Quadtree spatial indexing for efficient curve queries +//! +//! This module provides a quadtree data structure optimized for storing +//! bounding boxes of Bezier curve segments. It supports: +//! - Fast spatial queries (which curves intersect a region?) +//! - Auto-expanding boundary (grows to accommodate new curves) +//! - Efficient insertion and querying + +use vello::kurbo::{Point, Rect}; + +/// Axis-aligned bounding box +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BoundingBox { + pub x_min: f64, + pub x_max: f64, + pub y_min: f64, + pub y_max: f64, +} + +impl BoundingBox { + /// Create a new bounding box + pub fn new(x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> Self { + Self { + x_min, + x_max, + y_min, + y_max, + } + } + + /// Create a bounding box from a vello Rect + pub fn from_rect(rect: Rect) -> Self { + Self { + x_min: rect.x0, + x_max: rect.x1, + y_min: rect.y0, + y_max: rect.y1, + } + } + + /// Create a bounding box around a point with padding + pub fn around_point(point: Point, padding: f64) -> Self { + Self { + x_min: point.x - padding, + x_max: point.x + padding, + y_min: point.y - padding, + y_max: point.y + padding, + } + } + + /// Get the width of this bounding box + pub fn width(&self) -> f64 { + self.x_max - self.x_min + } + + /// Get the height of this bounding box + pub fn height(&self) -> f64 { + self.y_max - self.y_min + } + + /// Get the combined size (width + height) for threshold checks + pub fn size(&self) -> f64 { + self.width() + self.height() + } + + /// Check if this bounding box intersects with another + pub fn intersects(&self, other: &BoundingBox) -> bool { + !(other.x_max < self.x_min + || other.x_min > self.x_max + || other.y_max < self.y_min + || other.y_min > self.y_max) + } + + /// Check if this bounding box contains a point + pub fn contains_point(&self, point: Point) -> bool { + point.x >= self.x_min + && point.x <= self.x_max + && point.y >= self.y_min + && point.y <= self.y_max + } + + /// Check if this bounding box fully contains another bounding box + pub fn contains_bbox(&self, other: &BoundingBox) -> bool { + other.x_min >= self.x_min + && other.x_max <= self.x_max + && other.y_min >= self.y_min + && other.y_max <= self.y_max + } + + /// Get the center point of this bounding box + pub fn center(&self) -> Point { + Point::new( + (self.x_min + self.x_max) / 2.0, + (self.y_min + self.y_max) / 2.0, + ) + } + + /// Expand this bounding box to include another + pub fn expand_to_include(&mut self, other: &BoundingBox) { + self.x_min = self.x_min.min(other.x_min); + self.x_max = self.x_max.max(other.x_max); + self.y_min = self.y_min.min(other.y_min); + self.y_max = self.y_max.max(other.y_max); + } +} + +/// Quadtree for spatial indexing of curve segments +pub struct Quadtree { + /// Boundary of this quadtree node + boundary: BoundingBox, + /// Maximum number of items before subdivision + capacity: usize, + /// Curve indices and their bounding boxes stored in this node + items: Vec<(usize, BoundingBox)>, + /// Whether this node has been subdivided + divided: bool, + + // Child quadrants (only exist after subdivision) + nw: Option>, // Northwest (top-left) + ne: Option>, // Northeast (top-right) + sw: Option>, // Southwest (bottom-left) + se: Option>, // Southeast (bottom-right) +} + +impl Quadtree { + /// Create a new quadtree with the given boundary and capacity + pub fn new(boundary: BoundingBox, capacity: usize) -> Self { + Self { + boundary, + capacity, + items: Vec::new(), + divided: false, + nw: None, + ne: None, + sw: None, + se: None, + } + } + + /// Insert a curve's bounding box into the quadtree + /// + /// If the bbox doesn't fit in current boundary, the tree will expand. + /// Returns true if inserted successfully. + pub fn insert(&mut self, bbox: &BoundingBox, curve_idx: usize) -> bool { + // If bbox is outside our boundary, we need to expand + if !self.boundary.contains_bbox(bbox) { + self.expand_to_contain(bbox); + } + + self.insert_internal(bbox, curve_idx) + } + + /// Internal insertion that assumes bbox fits within boundary + fn insert_internal(&mut self, bbox: &BoundingBox, curve_idx: usize) -> bool { + // Early exit if bbox doesn't intersect this node at all + if !self.boundary.intersects(bbox) { + return false; + } + + // If we have space and haven't subdivided, store it here + if !self.divided && self.items.len() < self.capacity { + self.items.push((curve_idx, *bbox)); + return true; + } + + // Otherwise, subdivide if needed + if !self.divided { + self.subdivide(); + } + + // Try to insert into children (might go into multiple quadrants) + let mut inserted = false; + if let Some(ref mut nw) = self.nw { + inserted |= nw.insert_internal(bbox, curve_idx); + } + if let Some(ref mut ne) = self.ne { + inserted |= ne.insert_internal(bbox, curve_idx); + } + if let Some(ref mut sw) = self.sw { + inserted |= sw.insert_internal(bbox, curve_idx); + } + if let Some(ref mut se) = self.se { + inserted |= se.insert_internal(bbox, curve_idx); + } + + inserted + } + + /// Subdivide this node into 4 quadrants + fn subdivide(&mut self) { + let x_mid = (self.boundary.x_min + self.boundary.x_max) / 2.0; + let y_mid = (self.boundary.y_min + self.boundary.y_max) / 2.0; + + // Northwest (top-left) + self.nw = Some(Box::new(Quadtree::new( + BoundingBox::new( + self.boundary.x_min, + x_mid, + self.boundary.y_min, + y_mid, + ), + self.capacity, + ))); + + // Northeast (top-right) + self.ne = Some(Box::new(Quadtree::new( + BoundingBox::new(x_mid, self.boundary.x_max, self.boundary.y_min, y_mid), + self.capacity, + ))); + + // Southwest (bottom-left) + self.sw = Some(Box::new(Quadtree::new( + BoundingBox::new(self.boundary.x_min, x_mid, y_mid, self.boundary.y_max), + self.capacity, + ))); + + // Southeast (bottom-right) + self.se = Some(Box::new(Quadtree::new( + BoundingBox::new(x_mid, self.boundary.x_max, y_mid, self.boundary.y_max), + self.capacity, + ))); + + self.divided = true; + + // Re-insert existing items into children + let items_to_redistribute = std::mem::take(&mut self.items); + for (idx, bbox) in items_to_redistribute { + // Insert into all children that intersect with the bbox + if let Some(ref mut nw) = self.nw { + nw.insert_internal(&bbox, idx); + } + if let Some(ref mut ne) = self.ne { + ne.insert_internal(&bbox, idx); + } + if let Some(ref mut sw) = self.sw { + sw.insert_internal(&bbox, idx); + } + if let Some(ref mut se) = self.se { + se.insert_internal(&bbox, idx); + } + } + } + + /// Expand the quadtree to contain a bounding box that's outside current boundary + /// + /// This is the complex auto-expanding logic from the JS implementation. + fn expand_to_contain(&mut self, bbox: &BoundingBox) { + // Determine which direction we need to expand + let needs_expand_left = bbox.x_min < self.boundary.x_min; + let needs_expand_right = bbox.x_max > self.boundary.x_max; + let needs_expand_top = bbox.y_min < self.boundary.y_min; + let needs_expand_bottom = bbox.y_max > self.boundary.y_max; + + // Calculate the current width and height + let width = self.boundary.width(); + let height = self.boundary.height(); + + // Create a new root that's twice as large in the necessary direction(s) + let new_boundary = if needs_expand_left && needs_expand_top { + // Expand northwest + BoundingBox::new( + self.boundary.x_min - width, + self.boundary.x_max, + self.boundary.y_min - height, + self.boundary.y_max, + ) + } else if needs_expand_right && needs_expand_top { + // Expand northeast + BoundingBox::new( + self.boundary.x_min, + self.boundary.x_max + width, + self.boundary.y_min - height, + self.boundary.y_max, + ) + } else if needs_expand_left && needs_expand_bottom { + // Expand southwest + BoundingBox::new( + self.boundary.x_min - width, + self.boundary.x_max, + self.boundary.y_min, + self.boundary.y_max + height, + ) + } else if needs_expand_right && needs_expand_bottom { + // Expand southeast + BoundingBox::new( + self.boundary.x_min, + self.boundary.x_max + width, + self.boundary.y_min, + self.boundary.y_max + height, + ) + } else if needs_expand_left { + // Expand west + BoundingBox::new( + self.boundary.x_min - width, + self.boundary.x_max, + self.boundary.y_min, + self.boundary.y_max, + ) + } else if needs_expand_right { + // Expand east + BoundingBox::new( + self.boundary.x_min, + self.boundary.x_max + width, + self.boundary.y_min, + self.boundary.y_max, + ) + } else if needs_expand_top { + // Expand north + BoundingBox::new( + self.boundary.x_min, + self.boundary.x_max, + self.boundary.y_min - height, + self.boundary.y_max, + ) + } else { + // Expand south + BoundingBox::new( + self.boundary.x_min, + self.boundary.x_max, + self.boundary.y_min, + self.boundary.y_max + height, + ) + }; + + // Clone current tree to become a child of new root + let old_tree = Quadtree { + boundary: self.boundary, + capacity: self.capacity, + items: std::mem::take(&mut self.items), + divided: self.divided, + nw: self.nw.take(), + ne: self.ne.take(), + sw: self.sw.take(), + se: self.se.take(), + }; + + // Update self to be the new larger root + self.boundary = new_boundary; + self.items.clear(); + self.divided = true; + + // Create quadrants and place old tree in appropriate position + self.subdivide(); + + // Move old tree to appropriate quadrant + // When expanding diagonally, old tree goes in opposite corner + // When expanding in one direction, old tree takes up half the space + if needs_expand_left && needs_expand_top { + // Old tree was in bottom-right, new space is top-left + self.se = Some(Box::new(old_tree)); + } else if needs_expand_right && needs_expand_top { + // Old tree was in bottom-left, new space is top-right + self.sw = Some(Box::new(old_tree)); + } else if needs_expand_left && needs_expand_bottom { + // Old tree was in top-right, new space is bottom-left + self.ne = Some(Box::new(old_tree)); + } else if needs_expand_right && needs_expand_bottom { + // Old tree was in top-left, new space is bottom-right + self.nw = Some(Box::new(old_tree)); + } else { + // For single-direction expansion, just place the old tree + // We'll let it naturally distribute when items are inserted + // Place it in a quadrant that makes sense for the expansion direction + if needs_expand_left { + self.ne = Some(Box::new(old_tree)); + } else if needs_expand_right { + self.nw = Some(Box::new(old_tree)); + } else if needs_expand_top { + self.sw = Some(Box::new(old_tree)); + } else { + // needs_expand_bottom + self.nw = Some(Box::new(old_tree)); + } + } + } + + /// Query the quadtree for all curve indices that intersect with the given range + pub fn query(&self, range: &BoundingBox) -> Vec { + let mut found = Vec::new(); + self.query_internal(range, &mut found); + + // Remove duplicates + found.sort_unstable(); + found.dedup(); + + found + } + + /// Internal recursive query + fn query_internal(&self, range: &BoundingBox, found: &mut Vec) { + // If range doesn't intersect this node, nothing to do + if !self.boundary.intersects(range) { + return; + } + + // Add items from this node that actually intersect the query range + for (idx, bbox) in &self.items { + if bbox.intersects(range) { + found.push(*idx); + } + } + + // Recursively query children + if self.divided { + if let Some(ref nw) = self.nw { + nw.query_internal(range, found); + } + if let Some(ref ne) = self.ne { + ne.query_internal(range, found); + } + if let Some(ref sw) = self.sw { + sw.query_internal(range, found); + } + if let Some(ref se) = self.se { + se.query_internal(range, found); + } + } + } + + /// Clear all items from the quadtree + pub fn clear(&mut self) { + self.items.clear(); + self.divided = false; + self.nw = None; + self.ne = None; + self.sw = None; + self.se = None; + } + + /// Get the boundary of this quadtree + pub fn boundary(&self) -> &BoundingBox { + &self.boundary + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bounding_box_creation() { + let bbox = BoundingBox::new(0.0, 100.0, 0.0, 50.0); + assert_eq!(bbox.width(), 100.0); + assert_eq!(bbox.height(), 50.0); + assert_eq!(bbox.size(), 150.0); + } + + #[test] + fn test_bounding_box_intersects() { + let bbox1 = BoundingBox::new(0.0, 100.0, 0.0, 100.0); + let bbox2 = BoundingBox::new(50.0, 150.0, 50.0, 150.0); + let bbox3 = BoundingBox::new(200.0, 300.0, 200.0, 300.0); + + assert!(bbox1.intersects(&bbox2)); + assert!(bbox2.intersects(&bbox1)); + assert!(!bbox1.intersects(&bbox3)); + assert!(!bbox3.intersects(&bbox1)); + } + + #[test] + fn test_bounding_box_contains_point() { + let bbox = BoundingBox::new(0.0, 100.0, 0.0, 100.0); + + assert!(bbox.contains_point(Point::new(50.0, 50.0))); + assert!(bbox.contains_point(Point::new(0.0, 0.0))); + assert!(bbox.contains_point(Point::new(100.0, 100.0))); + assert!(!bbox.contains_point(Point::new(150.0, 50.0))); + assert!(!bbox.contains_point(Point::new(50.0, 150.0))); + } + + #[test] + fn test_quadtree_insert_and_query() { + let mut qt = Quadtree::new(BoundingBox::new(0.0, 100.0, 0.0, 100.0), 4); + + // Insert some curves + qt.insert(&BoundingBox::new(10.0, 20.0, 10.0, 20.0), 0); + qt.insert(&BoundingBox::new(30.0, 40.0, 30.0, 40.0), 1); + qt.insert(&BoundingBox::new(60.0, 70.0, 60.0, 70.0), 2); + + // Query overlapping region + let results = qt.query(&BoundingBox::new(15.0, 35.0, 15.0, 35.0)); + + assert!(results.contains(&0)); + assert!(results.contains(&1)); + assert!(!results.contains(&2)); + } + + #[test] + fn test_quadtree_subdivision() { + let mut qt = Quadtree::new(BoundingBox::new(0.0, 100.0, 0.0, 100.0), 2); + + // Insert enough items to force subdivision + qt.insert(&BoundingBox::new(10.0, 20.0, 10.0, 20.0), 0); + qt.insert(&BoundingBox::new(30.0, 40.0, 30.0, 40.0), 1); + qt.insert(&BoundingBox::new(60.0, 70.0, 60.0, 70.0), 2); + qt.insert(&BoundingBox::new(80.0, 90.0, 80.0, 90.0), 3); + + assert!(qt.divided); + + // Should still be able to query + let results = qt.query(&BoundingBox::new(0.0, 100.0, 0.0, 100.0)); + assert_eq!(results.len(), 4); + } + + #[test] + fn test_quadtree_clear() { + let mut qt = Quadtree::new(BoundingBox::new(0.0, 100.0, 0.0, 100.0), 4); + + qt.insert(&BoundingBox::new(10.0, 20.0, 10.0, 20.0), 0); + qt.insert(&BoundingBox::new(30.0, 40.0, 30.0, 40.0), 1); + + qt.clear(); + + let results = qt.query(&BoundingBox::new(0.0, 100.0, 0.0, 100.0)); + assert_eq!(results.len(), 0); + assert!(!qt.divided); + } + + #[test] + fn test_quadtree_auto_expand() { + let mut qt = Quadtree::new(BoundingBox::new(0.0, 100.0, 0.0, 100.0), 4); + + // Insert bbox outside current boundary + qt.insert(&BoundingBox::new(150.0, 200.0, 150.0, 200.0), 0); + + // Boundary should have expanded + assert!(qt.boundary().x_max >= 200.0 || qt.boundary().y_max >= 200.0); + + // Should be able to query the item + let results = qt.query(&BoundingBox::new(150.0, 200.0, 150.0, 200.0)); + assert!(results.contains(&0)); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/segment_builder.rs b/lightningbeam-ui/lightningbeam-core/src/segment_builder.rs new file mode 100644 index 0000000..0a5b9fd --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/segment_builder.rs @@ -0,0 +1,815 @@ +//! Segment builder for constructing filled paths from boundary points +//! +//! This module takes boundary points from flood fill and builds a closed path +//! by extracting curve segments and connecting them with intersections or bridges. + +use crate::curve_intersection::{deduplicate_intersections, find_intersections}; +use crate::curve_segment::CurveSegment; +use crate::flood_fill::BoundaryPoint; +use std::collections::HashMap; +use vello::kurbo::{BezPath, Point, Shape}; + +/// Configuration for segment building +pub struct SegmentBuilderConfig { + /// Maximum gap to bridge with a line segment + pub gap_threshold: f64, + /// Threshold for curve intersection detection + pub intersection_threshold: f64, +} + +impl Default for SegmentBuilderConfig { + fn default() -> Self { + Self { + gap_threshold: 2.0, + intersection_threshold: 0.5, + } + } +} + +/// A curve segment extracted from boundary points +#[derive(Debug, Clone)] +struct ExtractedSegment { + /// Original curve index + curve_index: usize, + /// Minimum parameter value from boundary points + t_min: f64, + /// Maximum parameter value from boundary points + t_max: f64, + /// The curve segment (trimmed to [t_min, t_max]) + segment: CurveSegment, +} + +/// Build a closed path from boundary points +/// +/// This function: +/// 1. Groups boundary points by curve +/// 2. Extracts curve segments for each group +/// 3. Connects adjacent segments (trimming at intersections or bridging gaps) +/// 4. Returns a closed BezPath +/// +/// Returns None if the region cannot be closed (gaps too large, etc.) +/// +/// The click_point parameter is used to verify that the found cycle actually +/// contains the clicked region. +pub fn build_path_from_boundary( + boundary_points: &[BoundaryPoint], + all_curves: &[CurveSegment], + config: &SegmentBuilderConfig, + click_point: Point, +) -> Option { + if boundary_points.is_empty() { + println!("build_path_from_boundary: No boundary points"); + return None; + } + + println!("build_path_from_boundary: Processing {} boundary points", boundary_points.len()); + + // Step 1: Group boundary points by curve and find parameter ranges + let extracted_segments = extract_segments(boundary_points, all_curves, click_point)?; + + println!("build_path_from_boundary: Extracted {} segments", extracted_segments.len()); + + if extracted_segments.is_empty() { + println!("build_path_from_boundary: No segments extracted"); + return None; + } + + // Step 2: Connect segments to form a closed path that contains the click point + let connected_segments = connect_segments(&extracted_segments, config, click_point)?; + + println!("build_path_from_boundary: Connected {} segments", connected_segments.len()); + + // Step 3: Build the final BezPath + Some(build_bez_path(&connected_segments)) +} + +/// Split segments at intersection points +/// This handles cases where curves cross in an X pattern +fn split_segments_at_intersections(segments: Vec) -> Vec { + use crate::curve_intersection::find_intersections; + + let mut result = Vec::new(); + let mut split_points: HashMap> = HashMap::new(); + + // Find all intersections between segments + for i in 0..segments.len() { + for j in (i + 1)..segments.len() { + let intersections = find_intersections(&segments[i].segment, &segments[j].segment, 0.5); + + for intersection in intersections { + // Record intersection parameters for both segments + split_points.entry(i).or_default().push(intersection.t1); + split_points.entry(j).or_default().push(intersection.t2); + } + } + } + + println!("split_segments_at_intersections: Found {} segments with intersections", split_points.len()); + + // Split each segment at its intersection points + let original_count = segments.len(); + for (idx, seg) in segments.into_iter().enumerate() { + if let Some(splits) = split_points.get(&idx) { + if splits.is_empty() { + result.push(seg); + continue; + } + + // Sort split points + let mut sorted_splits = splits.clone(); + sorted_splits.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + // Add endpoints + let mut all_t = vec![0.0]; + all_t.extend(sorted_splits.iter().copied()); + all_t.push(1.0); + + println!(" Splitting segment {} at {} points", idx, sorted_splits.len()); + + // Create sub-segments + for i in 0..(all_t.len() - 1) { + let t_start = all_t[i]; + let t_end = all_t[i + 1]; + + if (t_end - t_start).abs() < 0.001 { + continue; // Skip very small segments + } + + // Create a subsegment with adjusted t parameters + // The control_points stay the same, but we update t_start/t_end + let subseg = CurveSegment::new( + seg.segment.shape_index, + seg.segment.segment_index, + seg.segment.curve_type, + t_start, + t_end, + seg.segment.control_points.clone(), + ); + + result.push(ExtractedSegment { + curve_index: seg.curve_index, + t_min: t_start, + t_max: t_end, + segment: subseg, + }); + } + } else { + // No intersections, keep as-is + result.push(seg); + } + } + + println!("split_segments_at_intersections: {} segments -> {} segments after splitting", original_count, result.len()); + result +} + +/// Group boundary points by curve and extract segments +fn extract_segments( + boundary_points: &[BoundaryPoint], + all_curves: &[CurveSegment], + click_point: Point, +) -> Option> { + // Find the closest boundary point to the click + // Boundary points come from flood fill, so they're already from the correct region + println!("extract_segments: {} boundary points from flood fill", boundary_points.len()); + println!("extract_segments: Click point: ({:.1}, {:.1})", click_point.x, click_point.y); + + // Debug: print distribution of boundary points by curve + let mut curve_counts: std::collections::HashMap = std::collections::HashMap::new(); + for bp in boundary_points.iter() { + *curve_counts.entry(bp.curve_index).or_insert(0) += 1; + } + println!("extract_segments: Boundary points by curve:"); + for (curve_idx, count) in curve_counts.iter() { + println!(" Curve {}: {} points", curve_idx, count); + } + + // Debug: print first 5 boundary points + println!("extract_segments: First 5 boundary points:"); + for (i, bp) in boundary_points.iter().take(5).enumerate() { + println!(" {}: ({:.1}, {:.1}) curve {}", i, bp.point.x, bp.point.y, bp.curve_index); + } + + let mut closest_distance = f64::MAX; + let mut closest_boundary_point: Option<&BoundaryPoint> = None; + + for bp in boundary_points { + let distance = click_point.distance(bp.point); + if distance < closest_distance { + closest_distance = distance; + closest_boundary_point = Some(bp); + } + } + + let start_curve_idx = match closest_boundary_point { + Some(bp) => { + println!( + "extract_segments: Nearest boundary point at ({:.1}, {:.1}), distance: {:.1}, curve: {}", + bp.point.x, bp.point.y, closest_distance, bp.curve_index + ); + bp.curve_index + } + None => { + println!("extract_segments: No boundary points found"); + return None; + } + }; + + // We don't need to track nearest_point and nearest_t for the segment finding + // Just use the curve index to find segments after splitting + + // Group points by curve index + let mut curve_points: HashMap> = HashMap::new(); + for bp in boundary_points { + curve_points.entry(bp.curve_index).or_default().push(bp); + } + + // Extract segment for each curve + let mut segments = Vec::new(); + for (curve_idx, points) in curve_points { + if points.is_empty() { + continue; + } + + // Find min and max t parameters + let t_min = points + .iter() + .map(|p| p.t) + .min_by(|a, b| a.partial_cmp(b).unwrap())?; + let t_max = points + .iter() + .map(|p| p.t) + .max_by(|a, b| a.partial_cmp(b).unwrap())?; + + if curve_idx >= all_curves.len() { + continue; + } + + let original_curve = &all_curves[curve_idx]; + + // Use the full curve (t=0 to t=1) rather than just the portion touched by boundary points + // This ensures we don't create artificial gaps in closed regions + let segment = CurveSegment::new( + original_curve.shape_index, + original_curve.segment_index, + original_curve.curve_type, + 0.0, // Use full curve from start + 1.0, // to end + original_curve.control_points.clone(), + ); + + segments.push(ExtractedSegment { + curve_index: curve_idx, + t_min, + t_max, + segment, + }); + } + + if segments.is_empty() { + return None; + } + + // Split segments at intersection points + segments = split_segments_at_intersections(segments); + + // Find a segment from the ray-intersected curve to use as starting point + let start_segment_idx = segments + .iter() + .position(|seg| seg.curve_index == start_curve_idx); + + let start_segment_idx = match start_segment_idx { + Some(idx) => { + println!("extract_segments: Starting from segment {} (curve {})", idx, start_curve_idx); + idx + } + None => { + println!("extract_segments: No segment found for start curve {}", start_curve_idx); + return None; + } + }; + + // Reorder segments using graph-based cycle detection + // This finds the correct closed loop instead of greedy nearest-neighbor + // Higher threshold needed for split curves at intersections (floating point precision) + const CONNECTION_THRESHOLD: f64 = 5.0; // Endpoints within this distance can connect + + // Try to find a valid cycle that contains the click point + // Start from the specific segment where the nearest boundary point was found + // BFS will naturally only explore segments connected to this starting segment + match find_segment_cycle(&segments, CONNECTION_THRESHOLD, click_point, start_segment_idx) { + Some(ordered_segments) => { + println!("extract_segments: Found valid cycle with {} segments", ordered_segments.len()); + Some(ordered_segments) + } + None => { + println!("extract_segments: Could not find valid cycle through all segments"); + None + } + } +} + +/// Adjacency information for segment connections +struct SegmentConnections { + // Segments that can connect to the start point (when this segment is forward) + connects_to_start: Vec<(usize, bool, f64)>, // (index, reversed, distance) + // Segments that can connect to the end point (when this segment is forward) + connects_to_end: Vec<(usize, bool, f64)>, +} + +/// Find a cycle through segments that contains the click point +/// Returns segments in order with proper orientation +/// Starts ONLY from the given segment index +fn find_segment_cycle( + segments: &[ExtractedSegment], + threshold: f64, + click_point: Point, + start_segment_idx: usize, +) -> Option> { + if segments.is_empty() { + return None; + } + + println!("find_segment_cycle: Searching for cycle through {} segments", segments.len()); + + let mut connections: Vec = (0..segments.len()) + .map(|_| SegmentConnections { + connects_to_start: Vec::new(), + connects_to_end: Vec::new(), + }) + .collect(); + + // Build connectivity graph + for i in 0..segments.len() { + for j in 0..segments.len() { + if i == j { + continue; + } + + let seg_i = &segments[i]; + let seg_j = &segments[j]; + + // Check all four possible connections: + // 1. seg_i end -> seg_j start (both forward) + let dist_end_to_start = seg_i.segment.end_point().distance(seg_j.segment.start_point()); + if dist_end_to_start < threshold { + connections[i].connects_to_end.push((j, false, dist_end_to_start)); + } + + // 2. seg_i end -> seg_j end (j reversed) + let dist_end_to_end = seg_i.segment.end_point().distance(seg_j.segment.end_point()); + if dist_end_to_end < threshold { + connections[i].connects_to_end.push((j, true, dist_end_to_end)); + } + + // 3. seg_i start -> seg_j start (both forward, but we'd traverse i backwards) + let dist_start_to_start = seg_i.segment.start_point().distance(seg_j.segment.start_point()); + if dist_start_to_start < threshold { + connections[i].connects_to_start.push((j, false, dist_start_to_start)); + } + + // 4. seg_i start -> seg_j end (j reversed, i backwards) + let dist_start_to_end = seg_i.segment.start_point().distance(seg_j.segment.end_point()); + if dist_start_to_end < threshold { + connections[i].connects_to_start.push((j, true, dist_start_to_end)); + } + } + } + + // Debug: Print connectivity information + for i in 0..segments.len() { + println!( + " Segment {}: {} connections from end, {} from start", + i, + connections[i].connects_to_end.len(), + connections[i].connects_to_start.len() + ); + } + + // Use BFS to find the shortest cycle that contains the click point + // BFS naturally explores shorter paths first + // Start ONLY from the specified segment + bfs_find_shortest_cycle(&segments, &connections, threshold, click_point, start_segment_idx) +} + +/// Build a BezPath from ExtractedSegments (helper for testing containment) +fn build_bez_path_from_segments(segments: &[ExtractedSegment]) -> BezPath { + let mut path = BezPath::new(); + + if segments.is_empty() { + return path; + } + + // Start at the first point + let start_point = segments[0].segment.start_point(); + path.move_to(start_point); + + // Add all segments + for seg in segments { + let element = seg.segment.to_path_element(); + path.push(element); + } + + // Close the path + path.close_path(); + + path +} + +/// BFS to find the shortest cycle that contains the click point +/// Returns the first (shortest) cycle found that contains the click point +/// Starts ONLY from the specified segment index +fn bfs_find_shortest_cycle( + segments: &[ExtractedSegment], + connections: &[SegmentConnections], + threshold: f64, + click_point: Point, + start_segment_idx: usize, +) -> Option> { + use std::collections::VecDeque; + + // State: (current_segment_idx, current_reversed, path so far, visited set) + type State = (usize, bool, Vec<(usize, bool)>, Vec); + + if start_segment_idx >= segments.len() { + println!("bfs_find_shortest_cycle: Invalid start segment index {}", start_segment_idx); + return None; + } + + println!("bfs_find_shortest_cycle: Starting ONLY from segment {} (curve {})", + start_segment_idx, segments[start_segment_idx].curve_index); + + // Try starting from the one specified segment, in both orientations + for start_reversed in [false, true] { + let mut queue: VecDeque = VecDeque::new(); + let mut visited = vec![false; segments.len()]; + visited[start_segment_idx] = true; + + queue.push_back(( + start_segment_idx, + start_reversed, + vec![(start_segment_idx, start_reversed)], + visited.clone(), + )); + + while let Some((current_idx, current_reversed, path, visited)) = queue.pop_front() { + // Check if we can close the cycle (need at least 3 segments) + if path.len() >= 3 { + let first = &path[0]; + let current_end = if current_reversed { + segments[current_idx].segment.start_point() + } else { + segments[current_idx].segment.end_point() + }; + + let first_start = if first.1 { + segments[first.0].segment.end_point() + } else { + segments[first.0].segment.start_point() + }; + + let closing_gap = current_end.distance(first_start); + if closing_gap < threshold { + // Build final segment list with proper orientations + let mut result = Vec::new(); + for (idx, reversed) in path.iter() { + let mut seg = segments[*idx].clone(); + if *reversed { + seg.segment.control_points.reverse(); + } + result.push(seg); + } + + // Check if this cycle contains the click point + let test_path = build_bez_path_from_segments(&result); + let bbox = test_path.bounding_box(); + let winding = test_path.winding(click_point); + + println!( + " Testing {}-segment cycle: bbox=({:.1},{:.1})-({:.1},{:.1}), click=({:.1},{:.1}), winding={}", + result.len(), + bbox.x0, bbox.y0, bbox.x1, bbox.y1, + click_point.x, click_point.y, winding + ); + + if winding != 0 { + println!( + "bfs_find_shortest_cycle: Found cycle with {} segments (closing gap: {:.2}, winding: {})", + path.len(), + closing_gap, + winding + ); + return Some(result); + } else { + println!( + "bfs_find_shortest_cycle: Cycle doesn't contain click point (winding: 0), continuing search..." + ); + } + } + } + + // Explore neighbors + let next_connections = if current_reversed { + &connections[current_idx].connects_to_start + } else { + &connections[current_idx].connects_to_end + }; + + for (next_idx, next_reversed, _dist) in next_connections { + if !visited[*next_idx] { + let mut new_path = path.clone(); + new_path.push((*next_idx, *next_reversed)); + + let mut new_visited = visited.clone(); + new_visited[*next_idx] = true; + + queue.push_back((*next_idx, *next_reversed, new_path, new_visited)); + } + } + } + } + + println!("bfs_find_shortest_cycle: No cycle found"); + None +} + +/// Connected segment in the final path +#[derive(Debug, Clone)] +enum ConnectedSegment { + /// A curve segment from the original geometry + Curve { + segment: CurveSegment, + start: Point, + end: Point, + }, + /// A line segment bridging a gap + Line { start: Point, end: Point }, +} + +/// Connect extracted segments into a closed path that contains the click point +fn connect_segments( + extracted: &[ExtractedSegment], + config: &SegmentBuilderConfig, + click_point: Point, +) -> Option> { + if extracted.is_empty() { + println!("connect_segments: No segments to connect"); + return None; + } + + println!("connect_segments: Connecting {} segments", extracted.len()); + + let mut connected = Vec::new(); + + for i in 0..extracted.len() { + let current = &extracted[i]; + let next = &extracted[(i + 1) % extracted.len()]; + + // Get the current segment's endpoint + let current_end = current.segment.eval_at(1.0); + + // Get the next segment's start point + let next_start = next.segment.eval_at(0.0); + + // Add the current curve segment + connected.push(ConnectedSegment::Curve { + segment: current.segment.clone(), + start: current.segment.eval_at(0.0), + end: current_end, + }); + + // Check if we need to connect to the next segment + let gap = current_end.distance(next_start); + + println!("connect_segments: Gap between segment {} and {} is {:.2}", i, (i + 1) % extracted.len(), gap); + + if gap < 0.01 { + // Close enough, no bridge needed + continue; + } else if gap < config.gap_threshold { + // Bridge with a line segment + println!("connect_segments: Bridging gap with line segment"); + connected.push(ConnectedSegment::Line { + start: current_end, + end: next_start, + }); + } else { + // Try to find intersection + println!("connect_segments: Gap too large ({:.2}), trying to find intersection", gap); + let intersections = find_intersections( + ¤t.segment, + &next.segment, + config.intersection_threshold, + ); + + println!("connect_segments: Found {} intersections", intersections.len()); + + if !intersections.is_empty() { + // Use the first intersection to trim segments + let deduplicated = deduplicate_intersections(&intersections, 0.1); + println!("connect_segments: After deduplication: {} intersections", deduplicated.len()); + if !deduplicated.is_empty() { + // TODO: Properly trim the segments at the intersection + // For now, just bridge the gap + println!("connect_segments: Bridging gap at intersection"); + connected.push(ConnectedSegment::Line { + start: current_end, + end: next_start, + }); + } else { + // Gap too large and no intersection - fail + println!("connect_segments: FAILED - Gap too large and no deduplicated intersections"); + return None; + } + } else { + // Try bridging if within threshold + if gap < config.gap_threshold * 2.0 { + println!("connect_segments: Bridging gap (within 2x threshold)"); + connected.push(ConnectedSegment::Line { + start: current_end, + end: next_start, + }); + } else { + println!("connect_segments: FAILED - Gap too large ({:.2}) and no intersections", gap); + return None; + } + } + } + } + + println!("connect_segments: Successfully connected all segments"); + Some(connected) +} + +/// Build a BezPath from connected segments +fn build_bez_path(segments: &[ConnectedSegment]) -> BezPath { + let mut path = BezPath::new(); + + if segments.is_empty() { + return path; + } + + // Start at the first point + let start_point = match &segments[0] { + ConnectedSegment::Curve { start, .. } => *start, + ConnectedSegment::Line { start, .. } => *start, + }; + + path.move_to(start_point); + + // Add all segments + for segment in segments { + match segment { + ConnectedSegment::Curve { segment, .. } => { + let element = segment.to_path_element(); + path.push(element); + } + ConnectedSegment::Line { end, .. } => { + path.line_to(*end); + } + } + } + + // Close the path + path.close_path(); + + path +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::curve_segment::CurveType; + + #[test] + fn test_extract_segments_basic() { + let curves = vec![ + CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)], + ), + CurveSegment::new( + 1, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(100.0, 0.0), Point::new(100.0, 100.0)], + ), + ]; + + let boundary_points = vec![ + BoundaryPoint { + point: Point::new(25.0, 0.0), + curve_index: 0, + t: 0.25, + nearest_point: Point::new(25.0, 0.0), + distance: 0.0, + }, + BoundaryPoint { + point: Point::new(75.0, 0.0), + curve_index: 0, + t: 0.75, + nearest_point: Point::new(75.0, 0.0), + distance: 0.0, + }, + BoundaryPoint { + point: Point::new(100.0, 50.0), + curve_index: 1, + t: 0.5, + nearest_point: Point::new(100.0, 50.0), + distance: 0.0, + }, + ]; + + let segments = extract_segments(&boundary_points, &curves).unwrap(); + + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].curve_index, 0); + assert!((segments[0].t_min - 0.25).abs() < 1e-6); + assert!((segments[0].t_max - 0.75).abs() < 1e-6); + } + + #[test] + fn test_build_simple_path() { + let curves = vec![ + CurveSegment::new( + 0, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)], + ), + CurveSegment::new( + 1, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(100.0, 0.0), Point::new(100.0, 100.0)], + ), + CurveSegment::new( + 2, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(100.0, 100.0), Point::new(0.0, 100.0)], + ), + CurveSegment::new( + 3, + 0, + CurveType::Line, + 0.0, + 1.0, + vec![Point::new(0.0, 100.0), Point::new(0.0, 0.0)], + ), + ]; + + let boundary_points = vec![ + BoundaryPoint { + point: Point::new(50.0, 0.0), + curve_index: 0, + t: 0.5, + nearest_point: Point::new(50.0, 0.0), + distance: 0.0, + }, + BoundaryPoint { + point: Point::new(100.0, 50.0), + curve_index: 1, + t: 0.5, + nearest_point: Point::new(100.0, 50.0), + distance: 0.0, + }, + BoundaryPoint { + point: Point::new(50.0, 100.0), + curve_index: 2, + t: 0.5, + nearest_point: Point::new(50.0, 100.0), + distance: 0.0, + }, + BoundaryPoint { + point: Point::new(0.0, 50.0), + curve_index: 3, + t: 0.5, + nearest_point: Point::new(0.0, 50.0), + distance: 0.0, + }, + ]; + + let config = SegmentBuilderConfig::default(); + let click_point = Point::new(50.0, 50.0); // Center of the test square + let path = build_path_from_boundary(&boundary_points, &curves, &config, click_point); + + assert!(path.is_some()); + let path = path.unwrap(); + + // Should have a closed path + assert!(!path.elements().is_empty()); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/tolerance_quadtree.rs b/lightningbeam-ui/lightningbeam-core/src/tolerance_quadtree.rs new file mode 100644 index 0000000..87828f1 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/tolerance_quadtree.rs @@ -0,0 +1,353 @@ +//! Tolerance-based quadtree for proximity detection +//! +//! This quadtree subdivides until cells reach a minimum size (tolerance), +//! enabling efficient spatial queries for curves that are within tolerance +//! distance of each other. + +use crate::quadtree::BoundingBox; +use crate::shape::{Shape, ShapeColor, StrokeStyle}; +use std::collections::HashSet; +use vello::kurbo::{BezPath, CubicBez, ParamCurve, Shape as KurboShape}; + +/// Tolerance-based quadtree for spatial proximity detection +pub struct ToleranceQuadtree { + root: QuadtreeNode, + min_cell_size: f64, +} + +impl ToleranceQuadtree { + /// Create a new tolerance quadtree + /// + /// # Arguments + /// + /// * `bounds` - The bounding box of the entire space + /// * `min_cell_size` - Minimum cell size (tolerance) - cells won't subdivide smaller than this + pub fn new(bounds: BoundingBox, min_cell_size: f64) -> Self { + Self { + root: QuadtreeNode::new(bounds), + min_cell_size, + } + } + + /// Insert a curve into the quadtree + /// + /// The curve will be added to all cells it overlaps with. + pub fn insert_curve(&mut self, curve_id: usize, curve: &CubicBez) { + let bbox = BoundingBox::from_rect(curve.bounding_box()); + self.root + .insert(curve_id, &bbox, curve, self.min_cell_size); + } + + /// Finalize the quadtree after all curves have been inserted (Step 4) + /// + /// This removes curves from all non-leaf nodes, keeping them only in minimum-size cells. + /// Call this after inserting all curves. + pub fn finalize(&mut self) { + self.root.remove_curves_from_non_leaf_nodes(self.min_cell_size); + } + + /// Get all curves that share cells with the given curve + /// + /// Returns a set of unique curve IDs that are spatially nearby. + pub fn get_nearby_curves(&self, curve: &CubicBez) -> HashSet { + let bbox = BoundingBox::from_rect(curve.bounding_box()); + let mut nearby = HashSet::new(); + self.root.query(&bbox, &mut nearby); + nearby + } + + /// Get all curves in cells that overlap with the given bounding box + pub fn get_curves_in_region(&self, bbox: &BoundingBox) -> HashSet { + let mut curves = HashSet::new(); + self.root.query(bbox, &mut curves); + curves + } + + /// Render debug visualization of the quadtree + /// + /// Returns two shapes: one for non-leaf nodes (blue) and one for leaf nodes (green). + pub fn render_debug(&self) -> (Shape, Shape) { + let mut non_leaf_path = BezPath::new(); + let mut leaf_path = BezPath::new(); + self.root.render_debug(&mut non_leaf_path, &mut leaf_path, 0); + + let stroke_style = StrokeStyle { + width: 0.5, + ..Default::default() + }; + + let non_leaf_shape = Shape::new(non_leaf_path).with_stroke(ShapeColor::rgb(100, 100, 255), stroke_style.clone()); + let leaf_shape = Shape::new(leaf_path).with_stroke(ShapeColor::rgb(0, 200, 0), stroke_style); + + (non_leaf_shape, leaf_shape) + } +} + +/// A node in the tolerance quadtree +struct QuadtreeNode { + bounds: BoundingBox, + curves: Vec<(usize, BoundingBox)>, // (curve_id, bbox) + children: Option>, +} + +impl QuadtreeNode { + fn new(bounds: BoundingBox) -> Self { + Self { + bounds, + curves: Vec::new(), + children: None, + } + } + + fn is_subdividable(&self, min_size: f64) -> bool { + self.bounds.width() >= min_size * 2.0 && self.bounds.height() >= min_size * 2.0 + } + + fn subdivide(&mut self) { + let x_mid = (self.bounds.x_min + self.bounds.x_max) / 2.0; + let y_mid = (self.bounds.y_min + self.bounds.y_max) / 2.0; + + self.children = Some(Box::new([ + // Northwest (top-left) + QuadtreeNode::new(BoundingBox::new( + self.bounds.x_min, + x_mid, + self.bounds.y_min, + y_mid, + )), + // Northeast (top-right) + QuadtreeNode::new(BoundingBox::new( + x_mid, + self.bounds.x_max, + self.bounds.y_min, + y_mid, + )), + // Southwest (bottom-left) + QuadtreeNode::new(BoundingBox::new( + self.bounds.x_min, + x_mid, + y_mid, + self.bounds.y_max, + )), + // Southeast (bottom-right) + QuadtreeNode::new(BoundingBox::new( + x_mid, + self.bounds.x_max, + y_mid, + self.bounds.y_max, + )), + ])); + } + + fn insert( + &mut self, + curve_id: usize, + curve_bbox: &BoundingBox, + curve: &CubicBez, + min_size: f64, + ) { + // Step 2: Check if curve actually intersects this cell (not just bounding box) + if !self.curve_intersects_cell(curve) { + return; + } + + // Add curve to this cell + if !self.curves.iter().any(|(id, _)| *id == curve_id) { + self.curves.push((curve_id, curve_bbox.clone())); + } + + // Step 3: If this cell has at least one curve AND size > tolerance, subdivide + if self.is_subdividable(min_size) && !self.curves.is_empty() && self.children.is_none() { + self.subdivide(); + } + + // Recursively insert into children if they exist + // Each child only gets curves that actually intersect it (checked by curve_intersects_cell) + if let Some(ref mut children) = self.children { + for child in children.iter_mut() { + child.insert(curve_id, curve_bbox, curve, min_size); + } + } + } + + /// Check if a curve actually passes through this cell by sampling it + fn curve_intersects_cell(&self, curve: &CubicBez) -> bool { + // Sample the curve at multiple points to see if any fall within this cell + const SAMPLES: usize = 20; + for i in 0..=SAMPLES { + let t = i as f64 / SAMPLES as f64; + let point = curve.eval(t); + if self.bounds.contains_point(point) { + return true; + } + } + false + } + + /// Remove curves from all non-minimum-size cells (Step 4) + fn remove_curves_from_non_leaf_nodes(&mut self, min_size: f64) { + // If this cell has children, clear its curves and recurse + if self.children.is_some() { + self.curves.clear(); + + if let Some(ref mut children) = self.children { + for child in children.iter_mut() { + child.remove_curves_from_non_leaf_nodes(min_size); + } + } + } + // If no children, this is a leaf node - keep its curves + } + + fn query(&self, bbox: &BoundingBox, result: &mut HashSet) { + // If query bbox doesn't overlap this cell, skip + if !self.bounds.intersects(bbox) { + return; + } + + // Add all curves in this cell + for &(curve_id, _) in &self.curves { + result.insert(curve_id); + } + + // Query children + if let Some(ref children) = self.children { + for child in children.iter() { + child.query(bbox, result); + } + } + } + + fn render_debug(&self, non_leaf_path: &mut BezPath, leaf_path: &mut BezPath, depth: usize) { + use vello::kurbo::PathEl; + + // Choose which path to draw to based on whether this is a leaf node + let is_leaf = self.children.is_none(); + + // Draw cell boundary as outline only (not filled) + // Draw the four edges of the rectangle without closing the path + + // Helper closure to add rectangle to the appropriate path + let add_rect = |path: &mut BezPath| { + // Top edge + path.push(PathEl::MoveTo(vello::kurbo::Point::new( + self.bounds.x_min, + self.bounds.y_min, + ))); + path.push(PathEl::LineTo(vello::kurbo::Point::new( + self.bounds.x_max, + self.bounds.y_min, + ))); + + // Right edge + path.push(PathEl::MoveTo(vello::kurbo::Point::new( + self.bounds.x_max, + self.bounds.y_min, + ))); + path.push(PathEl::LineTo(vello::kurbo::Point::new( + self.bounds.x_max, + self.bounds.y_max, + ))); + + // Bottom edge + path.push(PathEl::MoveTo(vello::kurbo::Point::new( + self.bounds.x_max, + self.bounds.y_max, + ))); + path.push(PathEl::LineTo(vello::kurbo::Point::new( + self.bounds.x_min, + self.bounds.y_max, + ))); + + // Left edge + path.push(PathEl::MoveTo(vello::kurbo::Point::new( + self.bounds.x_min, + self.bounds.y_max, + ))); + path.push(PathEl::LineTo(vello::kurbo::Point::new( + self.bounds.x_min, + self.bounds.y_min, + ))); + }; + + if is_leaf { + add_rect(leaf_path); + } else { + add_rect(non_leaf_path); + } + + // Recursively render children + if let Some(ref children) = self.children { + for child in children.iter() { + child.render_debug(non_leaf_path, leaf_path, depth + 1); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vello::kurbo::Point; + + #[test] + fn test_create_tolerance_quadtree() { + let bounds = BoundingBox::new(0.0, 1000.0, 0.0, 1000.0); + let tolerance = 2.0; + let quadtree = ToleranceQuadtree::new(bounds, tolerance); + assert!(quadtree.root.is_subdividable(tolerance)); + } + + #[test] + fn test_insert_and_query() { + let bounds = BoundingBox::new(0.0, 1000.0, 0.0, 1000.0); + let mut quadtree = ToleranceQuadtree::new(bounds, 2.0); + + // Create a simple curve + let curve = CubicBez::new( + Point::new(100.0, 100.0), + Point::new(200.0, 100.0), + Point::new(200.0, 200.0), + Point::new(100.0, 200.0), + ); + + quadtree.insert_curve(0, &curve); + + // Query with the same curve should find it + let nearby = quadtree.get_nearby_curves(&curve); + assert!(nearby.contains(&0)); + } + + #[test] + fn test_nearby_curves() { + let bounds = BoundingBox::new(0.0, 1000.0, 0.0, 1000.0); + let mut quadtree = ToleranceQuadtree::new(bounds, 2.0); + + // Create two close curves + let curve1 = CubicBez::new( + Point::new(100.0, 100.0), + Point::new(200.0, 100.0), + Point::new(200.0, 200.0), + Point::new(100.0, 200.0), + ); + + let curve2 = CubicBez::new( + Point::new(150.0, 150.0), + Point::new(250.0, 150.0), + Point::new(250.0, 250.0), + Point::new(150.0, 250.0), + ); + + quadtree.insert_curve(0, &curve1); + quadtree.insert_curve(1, &curve2); + + // Both curves should find each other + let nearby1 = quadtree.get_nearby_curves(&curve1); + assert!(nearby1.contains(&0)); + assert!(nearby1.contains(&1)); + + let nearby2 = quadtree.get_nearby_curves(&curve2); + assert!(nearby2.contains(&0)); + assert!(nearby2.contains(&1)); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index b5cb61c..2a78fff 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1572,6 +1572,60 @@ impl StagePane { } } + fn handle_paint_bucket_tool( + &mut self, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::shape::ShapeColor; + use lightningbeam_core::actions::PaintBucketAction; + use vello::kurbo::Point; + + // Check if we have an active vector layer + let active_layer_id = match shared.active_layer_id { + Some(id) => id, + None => { + println!("Paint bucket: No active layer"); + return; + } + }; + + let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + Some(layer) => layer, + None => { + println!("Paint bucket: Layer not found"); + return; + } + }; + + // Only work on VectorLayer + if !matches!(active_layer, AnyLayer::Vector(_)) { + println!("Paint bucket: Not a vector layer"); + return; + } + + // On click: execute paint bucket fill + if response.clicked() { + let click_point = Point::new(world_pos.x as f64, world_pos.y as f64); + let fill_color = ShapeColor::from_egui(*shared.fill_color); + + println!("Paint bucket clicked at ({:.1}, {:.1})", click_point.x, click_point.y); + + // Create and execute paint bucket action + let action = PaintBucketAction::new( + *active_layer_id, + click_point, + fill_color, + 2.0, // tolerance - could be made configurable + lightningbeam_core::gap_handling::GapHandlingMode::BridgeSegment, + ); + shared.action_executor.execute(Box::new(action)); + println!("Paint bucket action executed"); + } + } + /// Apply transform preview to objects based on current mouse position fn apply_transform_preview( vector_layer: &mut lightningbeam_core::layer::VectorLayer, @@ -2508,6 +2562,9 @@ impl StagePane { Tool::Transform => { self.handle_transform_tool(ui, &response, world_pos, shared); } + Tool::PaintBucket => { + self.handle_paint_bucket_tool(&response, world_pos, shared); + } _ => { // Other tools not implemented yet }