diff --git a/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs b/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs index 36bcd51..19bba38 100644 --- a/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs +++ b/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs @@ -139,12 +139,25 @@ fn find_intersections_recursive( t2 = (t2 + dt2).clamp(0.0, 1.0); } // If Newton diverged far from the initial estimate, it may have - // jumped to a different crossing. Reject and fall back. + // jumped to a different crossing. Check if the refined result is + // actually better than the original before rejecting. + let p1_refined = orig_curve1.eval(t1); + let p2_refined = orig_curve2.eval(t2); + let err_refined = (p1_refined.x - p2_refined.x).powi(2) + + (p1_refined.y - p2_refined.y).powi(2); + if (t1 - t1_orig).abs() > (t1_end - t1_start) * 2.0 || (t2 - t2_orig).abs() > (t2_end - t2_start) * 2.0 { - t1 = t1_orig; - t2 = t2_orig; + let p1_orig = orig_curve1.eval(t1_orig); + let p2_orig = orig_curve2.eval(t2_orig); + let err_orig = (p1_orig.x - p2_orig.x).powi(2) + + (p1_orig.y - p2_orig.y).powi(2); + // Only fall back if the original is actually closer + if err_orig < err_refined { + t1 = t1_orig; + t2 = t2_orig; + } } let p1 = orig_curve1.eval(t1); diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 049203f..0bb31e6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -47,6 +47,7 @@ pub(crate) mod clipboard_platform; pub mod region_select; pub mod dcel2; pub use dcel2 as dcel; +pub mod vector_graph; pub mod svg_export; pub mod snap; pub mod webcam; diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs new file mode 100644 index 0000000..170c0f6 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs @@ -0,0 +1,1568 @@ +//! VectorGraph: a simple vertex+edge graph with explicit fill overlay. +//! +//! Replaces the DCEL for vector drawing storage. Key differences: +//! - No half-edges, no fan ordering invariant, no face objects +//! - Fills are stored as explicit boundary references, independent of topology +//! - Edges can be visible (strokes) or invisible (structural/gap-close) +//! - Curves are split at intersections; fills reference whole edges +//! +//! Lifecycle rules: +//! - Visible edge deleted by user → becomes invisible; removed only if no fill references it +//! - Fill deleted → its boundary edges checked; invisible unreferenced edges garbage collected +//! - Gap-close edges are invisible edges created by paint bucket with gap tolerance + +pub mod tests; + +use kurbo::{CubicBez, ParamCurve, Point}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +use crate::curve_intersections::find_curve_intersections; + +// --------------------------------------------------------------------------- +// Index types +// --------------------------------------------------------------------------- + +macro_rules! define_id { + ($name:ident) => { + #[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub struct $name(pub u32); + + impl $name { + pub const NONE: Self = Self(u32::MAX); + + #[inline] + pub fn is_none(self) -> bool { + self.0 == u32::MAX + } + + #[inline] + pub fn idx(self) -> usize { + self.0 as usize + } + } + + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_none() { + write!(f, "{}(NONE)", stringify!($name)) + } else { + write!(f, "{}({})", stringify!($name), self.0) + } + } + } + }; +} + +define_id!(VertexId); +define_id!(EdgeId); +define_id!(FillId); + +// --------------------------------------------------------------------------- +// Direction for traversing an edge +// --------------------------------------------------------------------------- + +/// Which direction to traverse an edge along its curve. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Direction { + /// Traverse from vertex[0] to vertex[1] (curve parameter 0→1) + Forward, + /// Traverse from vertex[1] to vertex[0] (curve parameter 1→0) + Backward, +} + +// --------------------------------------------------------------------------- +// Core structs +// --------------------------------------------------------------------------- + +/// A vertex in the graph — just a position. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Vertex { + pub position: Point, + pub deleted: bool, +} + +/// An edge: a cubic Bézier curve between two vertices, with optional stroke style. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Edge { + pub curve: CubicBez, + /// [start, end] — curve goes from vertices[0] to vertices[1]. + pub vertices: [VertexId; 2], + /// Stroke style. None = invisible (structural edge, e.g., gap-close). + pub stroke_style: Option, + /// Stroke color. None = invisible. + pub stroke_color: Option, + pub deleted: bool, +} + +/// A fill: an explicit boundary referencing edges, with a color. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Fill { + /// Ordered cycle of directed edge references forming the boundary. + pub boundary: Vec<(EdgeId, Direction)>, + pub color: ShapeColor, + pub fill_rule: FillRule, + pub deleted: bool, + // TODO: gradient_fill, image_fill +} + +// --------------------------------------------------------------------------- +// Placeholder types (to be replaced with real imports) +// --------------------------------------------------------------------------- + +// Re-export from shape module when wired up; for now define minimal versions +// so tests can compile. +pub use crate::shape::{FillRule, ShapeColor, StrokeStyle}; + +// --------------------------------------------------------------------------- +// VectorGraph container +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct VectorGraph { + pub vertices: Vec, + pub edges: Vec, + pub fills: Vec, + + free_vertices: Vec, + free_edges: Vec, + free_fills: Vec, +} + +impl Default for VectorGraph { + fn default() -> Self { + Self::new() + } +} + +impl VectorGraph { + pub fn new() -> Self { + Self { + vertices: Vec::new(), + edges: Vec::new(), + fills: Vec::new(), + free_vertices: Vec::new(), + free_edges: Vec::new(), + free_fills: Vec::new(), + } + } + + // ------------------------------------------------------------------- + // Allocation + // ------------------------------------------------------------------- + + pub fn alloc_vertex(&mut self, position: Point) -> VertexId { + if let Some(idx) = self.free_vertices.pop() { + let id = VertexId(idx); + self.vertices[id.idx()] = Vertex { + position, + deleted: false, + }; + id + } else { + let id = VertexId(self.vertices.len() as u32); + self.vertices.push(Vertex { + position, + deleted: false, + }); + id + } + } + + pub fn alloc_edge( + &mut self, + curve: CubicBez, + v0: VertexId, + v1: VertexId, + stroke_style: Option, + stroke_color: Option, + ) -> EdgeId { + let edge = Edge { + curve, + vertices: [v0, v1], + stroke_style, + stroke_color, + deleted: false, + }; + if let Some(idx) = self.free_edges.pop() { + let id = EdgeId(idx); + self.edges[id.idx()] = edge; + id + } else { + let id = EdgeId(self.edges.len() as u32); + self.edges.push(edge); + id + } + } + + pub fn alloc_fill( + &mut self, + boundary: Vec<(EdgeId, Direction)>, + color: ShapeColor, + fill_rule: FillRule, + ) -> FillId { + let fill = Fill { + boundary, + color, + fill_rule, + deleted: false, + }; + if let Some(idx) = self.free_fills.pop() { + let id = FillId(idx); + self.fills[id.idx()] = fill; + id + } else { + let id = FillId(self.fills.len() as u32); + self.fills.push(fill); + id + } + } + + // ------------------------------------------------------------------- + // Deallocation + // ------------------------------------------------------------------- + + pub fn free_vertex(&mut self, id: VertexId) { + debug_assert!(!id.is_none()); + self.vertices[id.idx()].deleted = true; + self.free_vertices.push(id.0); + } + + pub fn free_edge(&mut self, id: EdgeId) { + debug_assert!(!id.is_none()); + self.edges[id.idx()].deleted = true; + self.free_edges.push(id.0); + } + + pub fn free_fill(&mut self, id: FillId) { + debug_assert!(!id.is_none()); + self.fills[id.idx()].deleted = true; + self.free_fills.push(id.0); + } + + // ------------------------------------------------------------------- + // Accessors + // ------------------------------------------------------------------- + + #[inline] + pub fn vertex(&self, id: VertexId) -> &Vertex { + &self.vertices[id.idx()] + } + + #[inline] + pub fn vertex_mut(&mut self, id: VertexId) -> &mut Vertex { + &mut self.vertices[id.idx()] + } + + #[inline] + pub fn edge(&self, id: EdgeId) -> &Edge { + &self.edges[id.idx()] + } + + #[inline] + pub fn edge_mut(&mut self, id: EdgeId) -> &mut Edge { + &mut self.edges[id.idx()] + } + + #[inline] + pub fn fill(&self, id: FillId) -> &Fill { + &self.fills[id.idx()] + } + + #[inline] + pub fn fill_mut(&mut self, id: FillId) -> &mut Fill { + &mut self.fills[id.idx()] + } + + // ------------------------------------------------------------------- + // Adjacency queries + // ------------------------------------------------------------------- + + /// Get all non-deleted edges incident to a vertex. + pub fn edges_at_vertex(&self, vid: VertexId) -> Vec { + self.edges + .iter() + .enumerate() + .filter(|(_, e)| { + !e.deleted && (e.vertices[0] == vid || e.vertices[1] == vid) + }) + .map(|(i, _)| EdgeId(i as u32)) + .collect() + } + + /// Check if two vertices share an edge. + pub fn vertices_share_edge(&self, v0: VertexId, v1: VertexId) -> bool { + self.edges.iter().any(|e| { + !e.deleted + && ((e.vertices[0] == v0 && e.vertices[1] == v1) + || (e.vertices[0] == v1 && e.vertices[1] == v0)) + }) + } + + // ------------------------------------------------------------------- + // Edge visibility + // ------------------------------------------------------------------- + + /// Make an edge invisible (remove its stroke but keep it if fills reference it). + pub fn make_edge_invisible(&mut self, id: EdgeId) { + let edge = &mut self.edges[id.idx()]; + edge.stroke_style = None; + edge.stroke_color = None; + } + + /// Check if an edge is visible (has a stroke). + pub fn edge_is_visible(&self, id: EdgeId) -> bool { + let edge = &self.edges[id.idx()]; + edge.stroke_style.is_some() || edge.stroke_color.is_some() + } + + /// Check if any fill references this edge. + pub fn edge_has_fill_reference(&self, id: EdgeId) -> bool { + self.fills.iter().any(|f| { + !f.deleted && f.boundary.iter().any(|(eid, _)| *eid == id) + }) + } + + /// Garbage-collect invisible edges that no fill references. + pub fn gc_invisible_edges(&mut self) { + let to_free: Vec = (0..self.edges.len()) + .filter(|&i| { + let e = &self.edges[i]; + let eid = EdgeId(i as u32); + !e.deleted + && e.stroke_style.is_none() + && e.stroke_color.is_none() + && !self.fills.iter().any(|f| { + !f.deleted && f.boundary.iter().any(|(fe, _)| *fe == eid) + }) + }) + .map(|i| EdgeId(i as u32)) + .collect(); + + for eid in to_free { + self.free_edge(eid); + } + } + + // ------------------------------------------------------------------- + // Fill boundary → BezPath (for rendering) + // ------------------------------------------------------------------- + + /// Build a BezPath from a fill's boundary edges. + /// Handles `EdgeId::NONE` separators to start new contours (holes). + pub fn fill_to_bezpath(&self, fill_id: FillId) -> kurbo::BezPath { + let fill = &self.fills[fill_id.idx()]; + self.boundary_to_bezpath(&fill.boundary) + } + + // ------------------------------------------------------------------- + // Vertex editing + // ------------------------------------------------------------------- + + /// Update all edge curves incident to a vertex to reflect its current position. + /// Call this after moving a vertex to keep curves in sync. + pub fn update_edges_for_vertex(&mut self, vid: VertexId) { + let pos = self.vertices[vid.idx()].position; + for edge in &mut self.edges { + if edge.deleted { + continue; + } + if edge.vertices[0] == vid { + edge.curve.p0 = pos; + } + if edge.vertices[1] == vid { + edge.curve.p3 = pos; + } + } + } + + // ------------------------------------------------------------------- + // Vertex merging + // ------------------------------------------------------------------- + + /// Replace all references to `old` with `keep` in edges and fills, then free `old`. + pub fn merge_vertices(&mut self, keep: VertexId, old: VertexId) { + let keep_pos = self.vertices[keep.idx()].position; + for edge in &mut self.edges { + if edge.deleted { + continue; + } + if edge.vertices[0] == old { + edge.vertices[0] = keep; + edge.curve.p0 = keep_pos; + } + if edge.vertices[1] == old { + edge.vertices[1] = keep; + edge.curve.p3 = keep_pos; + } + } + self.vertices[old.idx()].deleted = true; + self.free_vertices.push(old.0); + } + + /// If a vertex is within snap_epsilon of another vertex, merge them. + /// Returns the surviving vertex ID. + pub fn try_merge_vertex(&mut self, vid: VertexId, snap_epsilon: f64) -> VertexId { + let pos = self.vertices[vid.idx()].position; + let eps_sq = snap_epsilon * snap_epsilon; + let mut best: Option<(VertexId, f64)> = None; + for (i, v) in self.vertices.iter().enumerate() { + let other = VertexId(i as u32); + if v.deleted || other == vid { + continue; + } + let dx = v.position.x - pos.x; + let dy = v.position.y - pos.y; + let dist_sq = dx * dx + dy * dy; + if dist_sq < eps_sq { + if best.is_none() || dist_sq < best.unwrap().1 { + best = Some((other, dist_sq)); + } + } + } + if let Some((keep, _)) = best { + self.merge_vertices(keep, vid); + keep + } else { + vid + } + } + + // ------------------------------------------------------------------- + // Helper: snap to existing vertex + // ------------------------------------------------------------------- + + /// Find the nearest non-deleted vertex within epsilon of a point. + pub fn snap_vertex(&self, point: Point, epsilon: f64) -> Option { + let eps_sq = epsilon * epsilon; + let mut best: Option<(VertexId, f64)> = None; + for (i, v) in self.vertices.iter().enumerate() { + if v.deleted { + continue; + } + let dx = v.position.x - point.x; + let dy = v.position.y - point.y; + let dist_sq = dx * dx + dy * dy; + if dist_sq < eps_sq { + if best.is_none() || dist_sq < best.unwrap().1 { + best = Some((VertexId(i as u32), dist_sq)); + } + } + } + best.map(|(id, _)| id) + } + + // ------------------------------------------------------------------- + // Topology operations + // ------------------------------------------------------------------- + + /// Split an edge at parameter t, creating a new vertex and replacing + /// the edge with two sub-edges. Updates any fills that reference the + /// original edge. + /// + /// The original edge_id is modified in-place to become sub_a (the first half). + /// A new edge is allocated for sub_b (the second half). + pub fn split_edge(&mut self, edge_id: EdgeId, t: f64) -> (VertexId, EdgeId, EdgeId) { + let edge = &self.edges[edge_id.idx()]; + let original_v0 = edge.vertices[0]; + let original_v1 = edge.vertices[1]; + let style = edge.stroke_style.clone(); + let color = edge.stroke_color; + let curve = edge.curve; + + let (left, right) = subdivide_cubic(curve, t); + let mid_v = self.alloc_vertex(left.p3); + + // Allocate both sub-edges as new edges + let sub_a = self.alloc_edge(left, original_v0, mid_v, style.clone(), color); + let sub_b = self.alloc_edge(right, mid_v, original_v1, style, color); + + // Update fills before freeing the old edge + self.update_fills_after_split(edge_id, sub_a, sub_b); + + // Free the original edge + self.edges[edge_id.idx()].deleted = true; + self.free_edges.push(edge_id.0); + + (mid_v, sub_a, sub_b) + } + + /// Update fill boundaries after an edge has been split into two sub-edges. + /// Replaces (old_edge, dir) with [(sub_a, dir), (sub_b, dir)] in all fills. + pub fn update_fills_after_split( + &mut self, + old_edge: EdgeId, + sub_a: EdgeId, + sub_b: EdgeId, + ) { + for fill in &mut self.fills { + if fill.deleted { + continue; + } + let mut new_boundary = Vec::with_capacity(fill.boundary.len() + 1); + let mut changed = false; + for &(eid, dir) in &fill.boundary { + if eid == old_edge { + changed = true; + match dir { + Direction::Forward => { + new_boundary.push((sub_a, Direction::Forward)); + new_boundary.push((sub_b, Direction::Forward)); + } + Direction::Backward => { + new_boundary.push((sub_b, Direction::Backward)); + new_boundary.push((sub_a, Direction::Backward)); + } + } + } else { + new_boundary.push((eid, dir)); + } + } + if changed { + fill.boundary = new_boundary; + } + } + } + + /// Insert a stroke (list of cubic segments) into the graph. + /// Finds intersections with existing edges, splits both, creates vertices. + /// Returns the new edge IDs. + pub fn insert_stroke( + &mut self, + segments: &[CubicBez], + stroke_style: Option, + stroke_color: Option, + snap_epsilon: f64, + ) -> Vec { + const ENDPOINT_T_MARGIN: f64 = 0.01; + + if segments.is_empty() { + return Vec::new(); + } + + // Pre-pass: check for self-intersections within the stroke segments. + // If segment i and segment j intersect (where j > i+1, or i==j for + // self-intersecting curves), we need to split them. + let mut expanded_segments: Vec = Vec::new(); + for seg in segments { + // Check single-curve self-intersection + if let Some((t1, t2, _point)) = find_cubic_self_intersection(seg) { + // Split at both t values + let (left, rest) = subdivide_cubic(*seg, t1); + let remapped_t2 = (t2 - t1) / (1.0 - t1); + let (mid, right) = subdivide_cubic(rest, remapped_t2); + expanded_segments.push(left); + expanded_segments.push(mid); + expanded_segments.push(right); + } else { + expanded_segments.push(*seg); + } + } + + // Check cross-segment intersections within the stroke itself + let mut i = 0; + while i < expanded_segments.len() { + let mut j = i + 2; // skip adjacent (they share an endpoint) + while j < expanded_segments.len() { + let ints = find_curve_intersections(&expanded_segments[i], &expanded_segments[j]); + if let Some(ix) = ints.first() { + let ti = ix.t1; + let tj = ix.t2.unwrap_or(0.5); + + // Split segment j first (higher index, won't shift i) + if tj > ENDPOINT_T_MARGIN && tj < 1.0 - ENDPOINT_T_MARGIN { + let (jl, jr) = subdivide_cubic(expanded_segments[j], tj); + expanded_segments[j] = jl; + expanded_segments.insert(j + 1, jr); + } + + // Split segment i + if ti > ENDPOINT_T_MARGIN && ti < 1.0 - ENDPOINT_T_MARGIN { + let (il, ir) = subdivide_cubic(expanded_segments[i], ti); + expanded_segments[i] = il; + expanded_segments.insert(i + 1, ir); + // Don't increment j — indices shifted, restart from i + break; + } + } + j += 1; + } + i += 1; + } + + let mut all_new_edges = Vec::new(); + let mut prev_end_vertex: Option = None; + + for (seg_idx, seg) in expanded_segments.iter().enumerate() { + // Snapshot existing edge count — only intersect with pre-existing edges + let edge_count = self.edges.len(); + + // Find intersections with existing edges and split them + let mut seg_splits: Vec<(f64, VertexId)> = Vec::new(); + + for ei in 0..edge_count { + let eid = EdgeId(ei as u32); + if self.edges[ei].deleted { + continue; + } + + let existing_curve = self.edges[ei].curve; + let ints = find_curve_intersections(seg, &existing_curve); + + // Collect valid intersections for this existing edge + let mut edge_hits: Vec<(f64, f64, Point)> = Vec::new(); + for ix in &ints { + let seg_t = ix.t1; + let edge_t = ix.t2.unwrap_or(0.5); + + let seg_near_endpoint = seg_t < ENDPOINT_T_MARGIN || seg_t > 1.0 - ENDPOINT_T_MARGIN; + let edge_near_endpoint = edge_t < ENDPOINT_T_MARGIN || edge_t > 1.0 - ENDPOINT_T_MARGIN; + + if edge_near_endpoint { + // Near endpoint of existing edge — snap to that vertex + if !seg_near_endpoint { + let vid = if edge_t < 0.5 { + self.edges[eid.idx()].vertices[0] + } else { + self.edges[eid.idx()].vertices[1] + }; + seg_splits.push((seg_t, vid)); + } + continue; + } + + // The existing edge needs splitting at edge_t regardless + // of whether the new segment is near its endpoint + edge_hits.push((seg_t, edge_t, ix.point)); + } + + // Sort by edge_t descending (high-to-low splitting) + edge_hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + let mut head_end = 1.0; + for (seg_t, original_edge_t, point) in edge_hits { + let remapped_t = original_edge_t / head_end; + let remapped_t = remapped_t.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN); + + let (mid_v, _sub_a, _sub_b) = self.split_edge(eid, remapped_t); + // Snap vertex to intersection point + self.vertices[mid_v.idx()].position = point; + // Merge with nearby existing vertex if within snap distance + let mid_v = self.try_merge_vertex(mid_v, snap_epsilon); + head_end = original_edge_t; + + // Only add as a segment split if not near an endpoint + let seg_near_endpoint = seg_t < ENDPOINT_T_MARGIN || seg_t > 1.0 - ENDPOINT_T_MARGIN; + if !seg_near_endpoint { + seg_splits.push((seg_t, mid_v)); + } + } + } + + // Resolve start vertex + let seg_start_pt = seg.p0; + let start_v = if let Some(prev) = prev_end_vertex { + prev + } else if let Some(vid) = self.snap_vertex(seg_start_pt, snap_epsilon) { + vid + } else { + self.alloc_vertex(seg_start_pt) + }; + + // Resolve end vertex + let seg_end_pt = seg.p3; + let is_last = seg_idx == expanded_segments.len() - 1; + let end_v = if !is_last { + // Check if next segment start snaps to an existing vertex + let next_start = expanded_segments[seg_idx + 1].p0; + if let Some(vid) = self.snap_vertex(next_start, snap_epsilon) { + vid + } else if let Some(vid) = self.snap_vertex(seg_end_pt, snap_epsilon) { + vid + } else { + self.alloc_vertex(seg_end_pt) + } + } else if let Some(vid) = self.snap_vertex(seg_end_pt, snap_epsilon) { + vid + } else { + self.alloc_vertex(seg_end_pt) + }; + + // Build chain: start + splits (sorted by t) + end + seg_splits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + // Dedup: remove splits too close together or to endpoints + let mut chain: Vec<(f64, VertexId)> = Vec::new(); + chain.push((0.0, start_v)); + for (t, vid) in &seg_splits { + if let Some(last) = chain.last() { + if (*t - last.0).abs() < ENDPOINT_T_MARGIN { + continue; + } + if last.1 == *vid { + continue; + } + } + if (1.0 - *t).abs() < ENDPOINT_T_MARGIN { + continue; + } + chain.push((*t, *vid)); + } + chain.push((1.0, end_v)); + + // Dedup consecutive same-vertex entries + chain.dedup_by(|b, a| a.1 == b.1); + + // Create edges for each consecutive pair in the chain + for pair in chain.windows(2) { + let (t0, v0) = pair[0]; + let (t1, v1) = pair[1]; + let sub_curve = subsegment_cubic(*seg, t0, t1); + // Snap curve endpoints to vertex positions + let mut snapped = sub_curve; + snapped.p0 = self.vertices[v0.idx()].position; + snapped.p3 = self.vertices[v1.idx()].position; + let eid = self.alloc_edge(snapped, v0, v1, stroke_style.clone(), stroke_color); + all_new_edges.push(eid); + } + + prev_end_vertex = Some(end_v); + } + + // Fill splitting pass: for each new edge, check if both endpoints + // lie on any fill's boundary — if so, split that fill. + let edges_to_check = all_new_edges.clone(); + for &eid in &edges_to_check { + let v0 = self.edges[eid.idx()].vertices[0]; + let v1 = self.edges[eid.idx()].vertices[1]; + + // Find fills where both v0 and v1 appear as boundary vertices + let fill_ids: Vec = self.fills + .iter() + .enumerate() + .filter(|(_, f)| !f.deleted) + .filter(|(_, f)| { + let has_v0 = f.boundary.iter().any(|&(be, _)| { + let e = &self.edges[be.idx()]; + e.vertices[0] == v0 || e.vertices[1] == v0 + }); + let has_v1 = f.boundary.iter().any(|&(be, _)| { + let e = &self.edges[be.idx()]; + e.vertices[0] == v1 || e.vertices[1] == v1 + }); + has_v0 && has_v1 + }) + .map(|(i, _)| FillId(i as u32)) + .collect(); + + for fid in fill_ids { + self.split_fill_by_edge(fid, eid); + } + } + + all_new_edges + } + + /// When a new edge splits a fill (both endpoints on the fill's boundary), + /// split the fill into two fills. + pub fn split_fill_by_edge( + &mut self, + fill_id: FillId, + splitting_edge: EdgeId, + ) -> Option<(FillId, FillId)> { + let fill = &self.fills[fill_id.idx()]; + if fill.deleted { + return None; + } + + let split_v0 = self.edges[splitting_edge.idx()].vertices[0]; + let split_v1 = self.edges[splitting_edge.idx()].vertices[1]; + + // Find the positions in the boundary where the splitting edge's + // endpoint vertices appear as the "arrival" vertex of a directed edge. + let boundary = fill.boundary.clone(); + + // Helper: get the "end" vertex of a directed boundary edge + let end_vertex = |eid: EdgeId, dir: Direction| -> VertexId { + match dir { + Direction::Forward => self.edges[eid.idx()].vertices[1], + Direction::Backward => self.edges[eid.idx()].vertices[0], + } + }; + + // Find positions where boundary edges arrive at split_v0 and split_v1 + let mut pos_v0: Option = None; + let mut pos_v1: Option = None; + + for (i, &(eid, dir)) in boundary.iter().enumerate() { + let ev = end_vertex(eid, dir); + if ev == split_v0 && pos_v0.is_none() { + pos_v0 = Some(i); + } + if ev == split_v1 && pos_v1.is_none() { + pos_v1 = Some(i); + } + } + + let pos_v0 = pos_v0?; + let pos_v1 = pos_v1?; + + // Ensure we have two distinct positions + if pos_v0 == pos_v1 { + return None; + } + + // Walk boundary in two halves: + // Half A: from pos_v0+1 to pos_v1 (inclusive), then splitting_edge Forward + // Half B: from pos_v1+1 to pos_v0 (wrapping), then splitting_edge Backward + let n = boundary.len(); + let color = fill.color; + let fill_rule = fill.fill_rule; + + let mut half_a = Vec::new(); + let mut idx = (pos_v0 + 1) % n; + loop { + half_a.push(boundary[idx]); + if idx == pos_v1 { + break; + } + idx = (idx + 1) % n; + } + half_a.push((splitting_edge, Direction::Forward)); + + let mut half_b = Vec::new(); + idx = (pos_v1 + 1) % n; + loop { + half_b.push(boundary[idx]); + if idx == pos_v0 { + break; + } + idx = (idx + 1) % n; + } + half_b.push((splitting_edge, Direction::Backward)); + + // Delete the original fill + self.fills[fill_id.idx()].deleted = true; + self.free_fills.push(fill_id.0); + + // Create two new fills + let fill_a = self.alloc_fill(half_a, color, fill_rule); + let fill_b = self.alloc_fill(half_b, color, fill_rule); + + Some((fill_a, fill_b)) + } + + /// Merge two fills that share a boundary edge (e.g., after edge deletion). + pub fn merge_fills(&mut self, fill_a: FillId, fill_b: FillId, shared_edge: EdgeId) -> FillId { + let boundary_a = self.fills[fill_a.idx()].boundary.clone(); + let boundary_b = self.fills[fill_b.idx()].boundary.clone(); + let color = self.fills[fill_a.idx()].color; + let fill_rule = self.fills[fill_a.idx()].fill_rule; + + // Find position of shared_edge in both boundaries + let pos_a = boundary_a.iter().position(|&(eid, _)| eid == shared_edge); + let pos_b = boundary_b.iter().position(|&(eid, _)| eid == shared_edge); + + if let (Some(pa), Some(pb)) = (pos_a, pos_b) { + // Build merged boundary: boundary_a without shared_edge + boundary_b without shared_edge + let na = boundary_a.len(); + let nb = boundary_b.len(); + + let mut merged = Vec::new(); + + // Walk boundary_a starting after the shared edge + for i in 1..na { + merged.push(boundary_a[(pa + i) % na]); + } + // Walk boundary_b starting after the shared edge + for i in 1..nb { + merged.push(boundary_b[(pb + i) % nb]); + } + + // Delete old fills + self.fills[fill_a.idx()].deleted = true; + self.free_fills.push(fill_a.0); + self.fills[fill_b.idx()].deleted = true; + self.free_fills.push(fill_b.0); + + self.alloc_fill(merged, color, fill_rule) + } else { + // Fallback: can't find shared edge, just keep fill_a + fill_a + } + } + + /// Delete an edge, handling fills: + /// - If exactly 2 fills reference it, merge them and free the edge + /// - If 1 fill references it, make it invisible + /// - If unreferenced, actually delete it + pub fn delete_edge_by_user(&mut self, id: EdgeId) { + // Find fills referencing this edge + let referencing_fills: Vec = self.fills + .iter() + .enumerate() + .filter(|(_, f)| !f.deleted && f.boundary.iter().any(|(eid, _)| *eid == id)) + .map(|(i, _)| FillId(i as u32)) + .collect(); + + match referencing_fills.len() { + 0 => self.free_edge(id), + 1 => self.make_edge_invisible(id), + 2 => { + self.merge_fills(referencing_fills[0], referencing_fills[1], id); + self.free_edge(id); + } + _ => self.make_edge_invisible(id), + } + } + + /// Trace the boundary of the region enclosing a point. + /// Returns the boundary as a list of (EdgeId, Direction) pairs, + /// or None if no enclosed region exists. + pub fn trace_boundary_at_point( + &mut self, + point: Point, + gap_tolerance: f64, + ) -> Option> { + if self.edges.iter().all(|e| e.deleted) { + return None; + } + + // Pre-bridge: find close approaches between non-connected edges + // and create invisible bridge edges before tracing. + if gap_tolerance > 0.0 { + self.create_gap_bridges(gap_tolerance); + } + + // Collect candidate boundaries from all nearby edges, both directions. + // Pick the smallest (by area) that contains the point. + let mut candidates: Vec> = Vec::new(); + + // Try tracing from every non-deleted edge, both directions + let edge_ids: Vec = self.edges + .iter() + .enumerate() + .filter(|(_, e)| !e.deleted) + .map(|(i, _)| EdgeId(i as u32)) + .collect(); + + for eid in &edge_ids { + for &dir in &[Direction::Forward, Direction::Backward] { + if let Some(boundary) = self.trace_boundary_walk(*eid, dir, gap_tolerance) { + let path = self.boundary_to_bezpath(&boundary); + let winding = kurbo::Shape::winding(&path, point); + if winding != 0 { + candidates.push(boundary); + } + } + } + } + + // Pick the smallest boundary by area + let mut outer = candidates.into_iter().min_by(|a, b| { + let area_a = self.boundary_area(a).abs(); + let area_b = self.boundary_area(b).abs(); + area_a.partial_cmp(&area_b).unwrap() + })?; + + // Hole detection: find edges inside the outer boundary that aren't part of it. + // Trace inner boundaries from them and append as hole contours. + let outer_path = self.boundary_to_bezpath(&outer); + let outer_area = kurbo::Shape::area(&outer_path); + let outer_edge_set: std::collections::HashSet = + outer.iter().map(|(eid, _)| *eid).collect(); + + // Find edges inside the outer boundary that aren't part of it + let interior_edges: Vec = self.edges.iter().enumerate() + .filter(|(_, e)| !e.deleted) + .map(|(i, _)| EdgeId(i as u32)) + .filter(|eid| !outer_edge_set.contains(eid)) + .filter(|eid| { + let c = &self.edges[eid.idx()].curve; + let mid = c.eval(0.5); + kurbo::Shape::winding(&outer_path, mid) != 0 + }) + .collect(); + + if !interior_edges.is_empty() { + // Trace boundaries from interior edges, collect hole contours + let mut used_edges: std::collections::HashSet = outer_edge_set; + let mut holes: Vec> = Vec::new(); + + for &eid in &interior_edges { + if used_edges.contains(&eid) { + continue; + } + for &dir in &[Direction::Forward, Direction::Backward] { + if let Some(boundary) = self.trace_boundary_walk(eid, dir, 0.0) { + // Check all edges in this boundary are interior + let all_interior = boundary.iter().all(|(e, _)| interior_edges.contains(e)); + if !all_interior { + continue; + } + let area = self.boundary_area(&boundary); + // Hole should have opposite sign from outer boundary + if (area > 0.0) != (outer_area > 0.0) { + for (e, _) in &boundary { + used_edges.insert(*e); + } + holes.push(boundary); + break; // Only need one direction per hole + } + } + } + } + + // Append holes with NONE separators + for hole in holes { + outer.push((EdgeId::NONE, Direction::Forward)); + outer.extend(hole); + } + } + + Some(outer) + } + + /// Paint bucket: trace boundary at point and create a fill. + pub fn paint_bucket( + &mut self, + point: Point, + color: ShapeColor, + fill_rule: FillRule, + gap_tolerance: f64, + ) -> Option { + let boundary = self.trace_boundary_at_point(point, gap_tolerance)?; + Some(self.alloc_fill(boundary, color, fill_rule)) + } + + // ------------------------------------------------------------------- + // Boundary tracing internals + // ------------------------------------------------------------------- + + /// Find the nearest non-deleted edge to a point. Returns (EdgeId, t, distance). + fn nearest_edge_to_point(&self, point: Point) -> Option<(EdgeId, f64, f64)> { + let mut best: Option<(EdgeId, f64, f64)> = None; + for (i, e) in self.edges.iter().enumerate() { + if e.deleted { + continue; + } + let eid = EdgeId(i as u32); + let (t, dist) = nearest_point_on_cubic(&e.curve, point); + if best.is_none() || dist < best.unwrap().2 { + best = Some((eid, t, dist)); + } + } + best + } + + /// Build a BezPath from a boundary (without storing it as a fill). + /// Handles `EdgeId::NONE` separators to start new contours (holes). + fn boundary_to_bezpath(&self, boundary: &[(EdgeId, Direction)]) -> kurbo::BezPath { + let mut path = kurbo::BezPath::new(); + if boundary.is_empty() { + return path; + } + let mut contour_started = false; + for &(eid, dir) in boundary { + if eid.is_none() { + // Separator: close current contour and start a new one + if contour_started { + path.close_path(); + contour_started = false; + } + continue; + } + let c = &self.edges[eid.idx()].curve; + match dir { + Direction::Forward => { + if !contour_started { + path.move_to(c.p0); + contour_started = true; + } + path.curve_to(c.p1, c.p2, c.p3); + } + Direction::Backward => { + if !contour_started { + path.move_to(c.p3); + contour_started = true; + } + path.curve_to(c.p2, c.p1, c.p0); + } + } + } + if contour_started { + path.close_path(); + } + path + } + + /// Pre-create invisible bridge edges for close approaches between + /// non-connected edges. Edges that share a vertex are skipped + /// (connected geometry should never be bridged). + fn create_gap_bridges(&mut self, gap_tolerance: f64) { + use crate::curve_intersections::find_closest_approach; + + let edge_count = self.edges.len(); + let edge_data: Vec<(EdgeId, CubicBez, VertexId, VertexId)> = (0..edge_count) + .filter(|&i| !self.edges[i].deleted) + .map(|i| { + let e = &self.edges[i]; + (EdgeId(i as u32), e.curve, e.vertices[0], e.vertices[1]) + }) + .collect(); + + let mut bridges: Vec<(Point, Point, EdgeId, f64, EdgeId, f64)> = Vec::new(); + + for i in 0..edge_data.len() { + for j in (i + 1)..edge_data.len() { + let (eid_i, curve_i, vi0, vi1) = &edge_data[i]; + let (eid_j, curve_j, vj0, vj1) = &edge_data[j]; + + // Skip if edges share a vertex (connected geometry) + if vi0 == vj0 || vi0 == vj1 || vi1 == vj0 || vi1 == vj1 { + continue; + } + + if let Some(approach) = find_closest_approach(curve_i, curve_j, gap_tolerance) { + bridges.push(( + approach.p1, approach.p2, + *eid_i, approach.t1, + *eid_j, approach.t2, + )); + } + } + } + const ENDPOINT_MARGIN: f64 = 0.05; + + for (p1, p2, eid_i, t_i, eid_j, t_j) in bridges { + // Resolve vertex for edge i: snap to endpoint or split + let v_i = if t_i < ENDPOINT_MARGIN { + self.edges[eid_i.idx()].vertices[0] + } else if t_i > 1.0 - ENDPOINT_MARGIN { + self.edges[eid_i.idx()].vertices[1] + } else { + let (mid, _, _) = self.split_edge(eid_i, t_i); + self.vertices[mid.idx()].position = p1; + self.update_edges_for_vertex(mid); + mid + }; + + // Resolve vertex for edge j: snap to endpoint or split + let v_j = if t_j < ENDPOINT_MARGIN { + self.edges[eid_j.idx()].vertices[0] + } else if t_j > 1.0 - ENDPOINT_MARGIN { + self.edges[eid_j.idx()].vertices[1] + } else { + let (mid, _, _) = self.split_edge(eid_j, t_j); + self.vertices[mid.idx()].position = p2; + self.update_edges_for_vertex(mid); + mid + }; + + let pa = self.vertices[v_i.idx()].position; + let pb = self.vertices[v_j.idx()].position; + let bridge_curve = line_cubic(pa, pb); + self.alloc_edge(bridge_curve, v_i, v_j, None, None); + } + } + + /// Compute the signed area of a boundary using the shoelace formula + /// on a linearized version of the path. + fn boundary_area(&self, boundary: &[(EdgeId, Direction)]) -> f64 { + let path = self.boundary_to_bezpath(boundary); + kurbo::Shape::area(&path) + } + + /// Walk the boundary starting from a given edge+direction, using CCW angle + /// selection at each vertex. Returns None if the walk doesn't form a cycle + /// or exceeds a maximum length. + fn trace_boundary_walk( + &mut self, + start_edge: EdgeId, + start_dir: Direction, + gap_tolerance: f64, + ) -> Option> { + let max_boundary_len = self.edges.len() * 2 + 10; + let mut boundary = Vec::new(); + let mut current_edge = start_edge; + let mut current_dir = start_dir; + + loop { + boundary.push((current_edge, current_dir)); + if boundary.len() > max_boundary_len { + return None; // Not converging + } + + // The vertex we arrive at + let arrival_vertex = match current_dir { + Direction::Forward => self.edges[current_edge.idx()].vertices[1], + Direction::Backward => self.edges[current_edge.idx()].vertices[0], + }; + + // The incoming angle (reversed — the direction we came from) + let incoming_angle = outgoing_angle_from_vertex( + &self.edges[current_edge.idx()].curve, + current_edge, + arrival_vertex, + &self.edges, + match current_dir { + // We arrived going forward, so at vertex[1] the "outgoing" in reverse is backward + Direction::Forward => Direction::Backward, + Direction::Backward => Direction::Forward, + }, + ); + + // Find all edges at this vertex + let incident: Vec = self.edges + .iter() + .enumerate() + .filter(|(_, e)| { + !e.deleted && (e.vertices[0] == arrival_vertex || e.vertices[1] == arrival_vertex) + }) + .map(|(i, _)| EdgeId(i as u32)) + .collect(); + + // For each incident edge (excluding the one we came from on the same side), + // compute the outgoing angle and pick the most clockwise turn (smallest CCW angle). + let mut best_next: Option<(EdgeId, Direction, f64)> = None; + + for &eid in &incident { + let edge = &self.edges[eid.idx()]; + // Determine direction(s) we can leave this vertex on this edge + let mut dirs = Vec::new(); + if edge.vertices[0] == arrival_vertex { + dirs.push(Direction::Forward); + } + if edge.vertices[1] == arrival_vertex { + dirs.push(Direction::Backward); + } + + for dir in dirs { + // Don't go back the way we came. + // If we arrived Forward (at vertices[1]), going back is Backward. + // If we arrived Backward (at vertices[0]), going back is Forward. + // In both cases, "going back" = same edge, opposite direction. + if eid == current_edge { + let reverse_dir = match current_dir { + Direction::Forward => Direction::Backward, + Direction::Backward => Direction::Forward, + }; + if dir == reverse_dir { + continue; + } + } + + let out_angle = outgoing_angle_from_vertex( + &edge.curve, + eid, + arrival_vertex, + &self.edges, + dir, + ); + + // CCW angle from incoming direction + let mut delta = out_angle - incoming_angle; + if delta <= 0.0 { + delta += std::f64::consts::TAU; + } + // We want the smallest positive CCW turn (most clockwise) + if best_next.is_none() || delta < best_next.unwrap().2 { + best_next = Some((eid, dir, delta)); + } + } + } + + if let Some((next_edge, next_dir, _delta)) = best_next { + current_edge = next_edge; + current_dir = next_dir; + } else if gap_tolerance > 0.0 { + // Dead end — try gap bridging + if let Some((bridge_edge, bridge_dir)) = + self.try_gap_bridge(arrival_vertex, current_edge, gap_tolerance) + { + current_edge = bridge_edge; + current_dir = bridge_dir; + } else { + return None; + } + } else { + return None; // Dead end with no gap tolerance + } + + // Check if we've returned to the start + if current_edge == start_edge && current_dir == start_dir { + break; + } + } + + if boundary.len() >= 2 { + Some(boundary) + } else { + None + } + } + + /// Try to bridge a gap at a dead-end vertex during boundary tracing. + /// Creates an invisible edge to the nearest reachable vertex/edge + /// that doesn't already share a vertex with the current edge. + fn try_gap_bridge( + &mut self, + from_vertex: VertexId, + current_edge: EdgeId, + gap_tolerance: f64, + ) -> Option<(EdgeId, Direction)> { + let from_pos = self.vertices[from_vertex.idx()].position; + let gap_tol_sq = gap_tolerance * gap_tolerance; + + // Find the current edge's vertices (to avoid bridging back to connected geometry) + let current_v0 = self.edges[current_edge.idx()].vertices[0]; + let current_v1 = self.edges[current_edge.idx()].vertices[1]; + + // Strategy 1: Find nearest vertex within tolerance that doesn't share + // a vertex with the current edge + let mut best_vertex: Option<(VertexId, f64)> = None; + for (i, v) in self.vertices.iter().enumerate() { + if v.deleted { + continue; + } + let vid = VertexId(i as u32); + if vid == from_vertex || vid == current_v0 || vid == current_v1 { + continue; + } + // Check this vertex isn't connected to from_vertex + if self.vertices_share_edge(from_vertex, vid) { + continue; + } + let dx = v.position.x - from_pos.x; + let dy = v.position.y - from_pos.y; + let dist_sq = dx * dx + dy * dy; + if dist_sq < gap_tol_sq { + if best_vertex.is_none() || dist_sq < best_vertex.unwrap().1 { + best_vertex = Some((vid, dist_sq)); + } + } + } + + // Strategy 2: Find nearest point on any edge (mid-curve gap close) + let mut best_edge_approach: Option<(EdgeId, f64, f64, Point)> = None; // (eid, t, dist_sq, point) + for (i, e) in self.edges.iter().enumerate() { + if e.deleted { + continue; + } + let eid = EdgeId(i as u32); + // Skip edges connected to from_vertex + if e.vertices[0] == from_vertex || e.vertices[1] == from_vertex { + continue; + } + // Skip edges connected to current edge's other vertex + if e.vertices[0] == current_v0 || e.vertices[1] == current_v0 + || e.vertices[0] == current_v1 || e.vertices[1] == current_v1 + { + continue; + } + let (t, dist) = nearest_point_on_cubic(&e.curve, from_pos); + let dist_sq = dist * dist; + if dist_sq < gap_tol_sq { + if best_edge_approach.is_none() || dist_sq < best_edge_approach.as_ref().unwrap().2 { + let pt = e.curve.eval(t); + best_edge_approach = Some((eid, t, dist_sq, pt)); + } + } + } + + // Pick the closer option + let vertex_dist_sq = best_vertex.map(|(_, d)| d).unwrap_or(f64::MAX); + let edge_dist_sq = best_edge_approach.as_ref().map(|x| x.2).unwrap_or(f64::MAX); + + if vertex_dist_sq < edge_dist_sq { + if let Some((target_vid, _)) = best_vertex { + // Create invisible bridge edge + let target_pos = self.vertices[target_vid.idx()].position; + let bridge_curve = line_cubic(from_pos, target_pos); + let bridge = self.alloc_edge(bridge_curve, from_vertex, target_vid, None, None); + return Some((bridge, Direction::Forward)); + } + } else if let Some((target_eid, t, _, point)) = best_edge_approach { + // Split the target edge at the closest point, then bridge to the new vertex + let (mid_v, _sub_a, _sub_b) = self.split_edge(target_eid, t); + self.vertices[mid_v.idx()].position = point; + let bridge_curve = line_cubic(from_pos, point); + let bridge = self.alloc_edge(bridge_curve, from_vertex, mid_v, None, None); + return Some((bridge, Direction::Forward)); + } + + None + } +} + +// --------------------------------------------------------------------------- +// Free functions: curve utilities +// --------------------------------------------------------------------------- + +/// De Casteljau subdivision of a cubic Bézier at parameter t. +fn subdivide_cubic(c: CubicBez, t: f64) -> (CubicBez, CubicBez) { + let p01 = lerp_point(c.p0, c.p1, t); + let p12 = lerp_point(c.p1, c.p2, t); + let p23 = lerp_point(c.p2, c.p3, t); + let p012 = lerp_point(p01, p12, t); + let p123 = lerp_point(p12, p23, t); + let p0123 = lerp_point(p012, p123, t); + ( + CubicBez::new(c.p0, p01, p012, p0123), + CubicBez::new(p0123, p123, p23, c.p3), + ) +} + +/// Extract a sub-curve for parameter range [t0, t1]. +fn subsegment_cubic(c: CubicBez, t0: f64, t1: f64) -> CubicBez { + const EPS: f64 = 1e-9; + if t0 < EPS && t1 > 1.0 - EPS { + return c; + } + if t0 < EPS { + return subdivide_cubic(c, t1).0; + } + if t1 > 1.0 - EPS { + return subdivide_cubic(c, t0).1; + } + let (_, upper) = subdivide_cubic(c, t0); + let remapped = (t1 - t0) / (1.0 - t0); + subdivide_cubic(upper, remapped).0 +} + +#[inline] +fn lerp_point(a: Point, b: Point, t: f64) -> Point { + Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t) +} + +/// Create a straight-line cubic Bézier from a to b. +fn line_cubic(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + lerp_point(a, b, 1.0 / 3.0), + lerp_point(a, b, 2.0 / 3.0), + b, + ) +} + +/// Algebraic self-intersection detection for a cubic Bézier. +fn find_cubic_self_intersection(curve: &CubicBez) -> Option<(f64, f64, Point)> { + const ENDPOINT_T_MARGIN: f64 = 0.01; + + let ax = curve.p1.x - curve.p0.x; + let ay = curve.p1.y - curve.p0.y; + let bx = curve.p2.x - 2.0 * curve.p1.x + curve.p0.x; + let by = curve.p2.y - 2.0 * curve.p1.y + curve.p0.y; + let cx = curve.p3.x - 3.0 * curve.p2.x + 3.0 * curve.p1.x - curve.p0.x; + let cy = curve.p3.y - 3.0 * curve.p2.y + 3.0 * curve.p1.y - curve.p0.y; + + let b_cross_c = bx * cy - by * cx; + if b_cross_c.abs() < 1e-10 { + return None; + } + + let a_cross_c = ax * cy - ay * cx; + let s = -a_cross_c / b_cross_c; + + // Back-substitute for p + let p = if cx.abs() > cy.abs() { + if cx.abs() < 1e-10 { + return None; + } + s * s + (3.0 * bx * s + 3.0 * ax) / cx + } else { + if cy.abs() < 1e-10 { + return None; + } + s * s + (3.0 * by * s + 3.0 * ay) / cy + }; + + let disc = s * s - 4.0 * p; + if disc < 0.0 { + return None; + } + + let sqrt_disc = disc.sqrt(); + let t1 = (s - sqrt_disc) / 2.0; + let t2 = (s + sqrt_disc) / 2.0; + + if t1 <= ENDPOINT_T_MARGIN || t2 >= 1.0 - ENDPOINT_T_MARGIN || t1 >= t2 { + return None; + } + + let pt1 = curve.eval(t1); + let pt2 = curve.eval(t2); + let point = Point::new((pt1.x + pt2.x) / 2.0, (pt1.y + pt2.y) / 2.0); + Some((t1, t2, point)) +} + +/// Compute the tangent of a cubic at parameter t (unnormalized). +fn cubic_tangent(c: &CubicBez, t: f64) -> Point { + let mt = 1.0 - t; + let x = 3.0 * (mt * mt * (c.p1.x - c.p0.x) + + 2.0 * mt * t * (c.p2.x - c.p1.x) + + t * t * (c.p3.x - c.p2.x)); + let y = 3.0 * (mt * mt * (c.p1.y - c.p0.y) + + 2.0 * mt * t * (c.p2.y - c.p1.y) + + t * t * (c.p3.y - c.p2.y)); + Point::new(x, y) +} + +/// Compute the outgoing angle of an edge leaving a vertex. +fn outgoing_angle_from_vertex( + curve: &CubicBez, + _edge_id: EdgeId, + _vertex: VertexId, + edges: &[Edge], + dir: Direction, +) -> f64 { + let _ = edges; + let tangent = match dir { + Direction::Forward => cubic_tangent(curve, 0.0), + Direction::Backward => { + let t = cubic_tangent(curve, 1.0); + Point::new(-t.x, -t.y) + } + }; + tangent.y.atan2(tangent.x) +} + +/// Find the nearest point on a cubic Bézier to a given point. +/// Returns (t, distance). +fn nearest_point_on_cubic(curve: &CubicBez, point: Point) -> (f64, f64) { + // Sample at regular intervals, then refine with Newton's method + let n = 32; + let mut best_t = 0.0; + let mut best_dist_sq = f64::MAX; + + for i in 0..=n { + let t = i as f64 / n as f64; + let p = curve.eval(t); + let dx = p.x - point.x; + let dy = p.y - point.y; + let dist_sq = dx * dx + dy * dy; + if dist_sq < best_dist_sq { + best_dist_sq = dist_sq; + best_t = t; + } + } + + // Newton refinement + for _ in 0..8 { + let p = curve.eval(best_t); + let d = cubic_tangent(curve, best_t); + let diff = Point::new(p.x - point.x, p.y - point.y); + let dot = diff.x * d.x + diff.y * d.y; + let d2 = d.x * d.x + d.y * d.y; + if d2.abs() < 1e-12 { + break; + } + let dt = -dot / d2; + best_t = (best_t + dt).clamp(0.0, 1.0); + } + + let p = curve.eval(best_t); + let dx = p.x - point.x; + let dy = p.y - point.y; + (best_t, (dx * dx + dy * dy).sqrt()) +} diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/basic.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/basic.rs new file mode 100644 index 0000000..466bbe4 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/basic.rs @@ -0,0 +1,253 @@ +//! Basic graph construction, vertex/edge/fill CRUD, adjacency, BezPath generation. + +use super::super::*; +use kurbo::{CubicBez, Point}; + +/// Helper: create a straight-line cubic Bézier from a to b. +fn line(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0), + Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0), + b, + ) +} + +// ── Vertex CRUD ────────────────────────────────────────────────────────── + +#[test] +fn alloc_vertex() { + let mut g = VectorGraph::new(); + let v = g.alloc_vertex(Point::new(10.0, 20.0)); + assert_eq!(g.vertex(v).position, Point::new(10.0, 20.0)); + assert!(!g.vertex(v).deleted); +} + +#[test] +fn free_and_reuse_vertex() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(1.0, 2.0)); + g.free_vertex(v0); + assert!(g.vertex(v0).deleted); + + // Next alloc should reuse the freed slot + let v1 = g.alloc_vertex(Point::new(3.0, 4.0)); + assert_eq!(v0, v1); + assert_eq!(g.vertex(v1).position, Point::new(3.0, 4.0)); + assert!(!g.vertex(v1).deleted); +} + +// ── Edge CRUD ──────────────────────────────────────────────────────────── + +#[test] +fn alloc_edge_with_stroke() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(0.0, 0.0)); + let v1 = g.alloc_vertex(Point::new(100.0, 0.0)); + let style = StrokeStyle { width: 2.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + + let e = g.alloc_edge(line(Point::ZERO, Point::new(100.0, 0.0)), v0, v1, Some(style), Some(color)); + assert_eq!(g.edge(e).vertices, [v0, v1]); + assert!(g.edge_is_visible(e)); +} + +#[test] +fn alloc_invisible_edge() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(0.0, 0.0)); + let v1 = g.alloc_vertex(Point::new(50.0, 0.0)); + let e = g.alloc_edge(line(Point::ZERO, Point::new(50.0, 0.0)), v0, v1, None, None); + assert!(!g.edge_is_visible(e)); +} + +// ── Fill CRUD ──────────────────────────────────────────────────────────── + +#[test] +fn alloc_fill_with_boundary() { + let mut g = VectorGraph::new(); + + // Build a triangle: 3 vertices, 3 edges + let p0 = Point::new(0.0, 0.0); + let p1 = Point::new(100.0, 0.0); + let p2 = Point::new(50.0, 100.0); + + let v0 = g.alloc_vertex(p0); + let v1 = g.alloc_vertex(p1); + let v2 = g.alloc_vertex(p2); + + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let e0 = g.alloc_edge(line(p0, p1), v0, v1, Some(style.clone()), Some(color)); + let e1 = g.alloc_edge(line(p1, p2), v1, v2, Some(style.clone()), Some(color)); + let e2 = g.alloc_edge(line(p2, p0), v2, v0, Some(style), Some(color)); + + let boundary = vec![ + (e0, Direction::Forward), + (e1, Direction::Forward), + (e2, Direction::Forward), + ]; + let fill_color = ShapeColor::rgb(255, 0, 0); + let fid = g.alloc_fill(boundary, fill_color, FillRule::NonZero); + + assert_eq!(g.fill(fid).boundary.len(), 3); + assert_eq!(g.fill(fid).color, fill_color); +} + +// ── Adjacency ──────────────────────────────────────────────────────────── + +#[test] +fn edges_at_vertex_finds_incident() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(50.0, 50.0)); + let v1 = g.alloc_vertex(Point::new(100.0, 50.0)); + let v2 = g.alloc_vertex(Point::new(50.0, 100.0)); + let v3 = g.alloc_vertex(Point::new(0.0, 50.0)); + + let e0 = g.alloc_edge(line(Point::new(50.0, 50.0), Point::new(100.0, 50.0)), v0, v1, None, None); + let e1 = g.alloc_edge(line(Point::new(50.0, 50.0), Point::new(50.0, 100.0)), v0, v2, None, None); + let _e2 = g.alloc_edge(line(Point::new(0.0, 50.0), Point::new(100.0, 50.0)), v3, v1, None, None); + + let incident = g.edges_at_vertex(v0); + assert_eq!(incident.len(), 2); + assert!(incident.contains(&e0)); + assert!(incident.contains(&e1)); +} + +#[test] +fn vertices_share_edge_check() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(0.0, 0.0)); + let v1 = g.alloc_vertex(Point::new(10.0, 0.0)); + let v2 = g.alloc_vertex(Point::new(20.0, 0.0)); + + g.alloc_edge(line(Point::ZERO, Point::new(10.0, 0.0)), v0, v1, None, None); + + assert!(g.vertices_share_edge(v0, v1)); + assert!(g.vertices_share_edge(v1, v0)); // symmetric + assert!(!g.vertices_share_edge(v0, v2)); +} + +// ── Edge visibility + deletion ─────────────────────────────────────────── + +#[test] +fn delete_visible_edge_without_fill_removes_it() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::ZERO); + let v1 = g.alloc_vertex(Point::new(10.0, 0.0)); + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let e = g.alloc_edge(line(Point::ZERO, Point::new(10.0, 0.0)), v0, v1, Some(style), Some(ShapeColor::rgb(0, 0, 0))); + + g.delete_edge_by_user(e); + assert!(g.edge(e).deleted); +} + +#[test] +fn delete_edge_with_fill_makes_invisible() { + let mut g = VectorGraph::new(); + + // Triangle with a fill + let p0 = Point::new(0.0, 0.0); + let p1 = Point::new(100.0, 0.0); + let p2 = Point::new(50.0, 100.0); + let v0 = g.alloc_vertex(p0); + let v1 = g.alloc_vertex(p1); + let v2 = g.alloc_vertex(p2); + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let e0 = g.alloc_edge(line(p0, p1), v0, v1, Some(style.clone()), Some(color)); + let e1 = g.alloc_edge(line(p1, p2), v1, v2, Some(style.clone()), Some(color)); + let e2 = g.alloc_edge(line(p2, p0), v2, v0, Some(style), Some(color)); + + let boundary = vec![ + (e0, Direction::Forward), + (e1, Direction::Forward), + (e2, Direction::Forward), + ]; + let _fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + + // Delete one edge — should become invisible, not deleted + g.delete_edge_by_user(e0); + assert!(!g.edge(e0).deleted, "edge should not be deleted while fill references it"); + assert!(!g.edge_is_visible(e0), "edge should be invisible"); +} + +#[test] +fn gc_removes_invisible_unreferenced_edges() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::ZERO); + let v1 = g.alloc_vertex(Point::new(10.0, 0.0)); + + // Invisible edge with no fill referencing it + let e = g.alloc_edge(line(Point::ZERO, Point::new(10.0, 0.0)), v0, v1, None, None); + assert!(!g.edge(e).deleted); + + g.gc_invisible_edges(); + assert!(g.edge(e).deleted, "invisible unreferenced edge should be garbage collected"); +} + +// ── BezPath generation ─────────────────────────────────────────────────── + +#[test] +fn fill_to_bezpath_generates_closed_path() { + let mut g = VectorGraph::new(); + + // Square + let tl = Point::new(0.0, 0.0); + let tr = Point::new(100.0, 0.0); + let br = Point::new(100.0, 100.0); + let bl = Point::new(0.0, 100.0); + + let v_tl = g.alloc_vertex(tl); + let v_tr = g.alloc_vertex(tr); + let v_br = g.alloc_vertex(br); + let v_bl = g.alloc_vertex(bl); + + let e0 = g.alloc_edge(line(tl, tr), v_tl, v_tr, None, None); + let e1 = g.alloc_edge(line(tr, br), v_tr, v_br, None, None); + let e2 = g.alloc_edge(line(br, bl), v_br, v_bl, None, None); + let e3 = g.alloc_edge(line(bl, tl), v_bl, v_tl, None, None); + + let boundary = vec![ + (e0, Direction::Forward), + (e1, Direction::Forward), + (e2, Direction::Forward), + (e3, Direction::Forward), + ]; + let fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + + let path = g.fill_to_bezpath(fid); + let elements: Vec<_> = path.elements().to_vec(); + + // Should be: MoveTo, CurveTo x4, ClosePath + assert_eq!(elements.len(), 6); + assert!(matches!(elements[0], kurbo::PathEl::MoveTo(_))); + assert!(matches!(elements[5], kurbo::PathEl::ClosePath)); +} + +#[test] +fn fill_to_bezpath_respects_direction() { + let mut g = VectorGraph::new(); + + let p0 = Point::new(0.0, 0.0); + let p1 = Point::new(100.0, 0.0); + let v0 = g.alloc_vertex(p0); + let v1 = g.alloc_vertex(p1); + let e = g.alloc_edge(line(p0, p1), v0, v1, None, None); + + // Forward: start at p0 + let fwd_boundary = vec![(e, Direction::Forward)]; + let fid_fwd = g.alloc_fill(fwd_boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + let path_fwd = g.fill_to_bezpath(fid_fwd); + if let kurbo::PathEl::MoveTo(start) = path_fwd.elements()[0] { + assert!((start.x - p0.x).abs() < 0.01); + } + + // Backward: start at p1 + let bwd_boundary = vec![(e, Direction::Backward)]; + let fid_bwd = g.alloc_fill(bwd_boundary, ShapeColor::rgb(0, 255, 0), FillRule::NonZero); + let path_bwd = g.fill_to_bezpath(fid_bwd); + if let kurbo::PathEl::MoveTo(start) = path_bwd.elements()[0] { + assert!((start.x - p1.x).abs() < 0.01); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/editing.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/editing.rs new file mode 100644 index 0000000..b2343b2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/editing.rs @@ -0,0 +1,322 @@ +//! Vertex dragging, curve editing, edge deletion, and fill response. + +use super::super::*; +use kurbo::{CubicBez, Point}; + +fn line(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0), + Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0), + b, + ) +} + +fn black_stroke() -> (Option, Option) { + (Some(StrokeStyle { width: 2.0, ..Default::default() }), Some(ShapeColor::rgb(0, 0, 0))) +} + +// ── Vertex dragging ────────────────────────────────────────────────────── + +#[test] +fn drag_vertex_moves_connected_edges() { + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + // Two edges sharing a vertex at (50, 50) + // (0, 0) → (50, 50) → (100, 0) + g.insert_stroke( + &[ + line(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), + line(Point::new(50.0, 50.0), Point::new(100.0, 0.0)), + ], + style, color, 0.5, + ); + + // Find the shared vertex at (50, 50) + let mid_v = g.vertices.iter().enumerate() + .find(|(_, v)| !v.deleted && (v.position.x - 50.0).abs() < 1.0 && (v.position.y - 50.0).abs() < 1.0) + .map(|(i, _)| VertexId(i as u32)) + .expect("should find vertex at (50, 50)"); + + // Move it to (50, 80) + g.vertex_mut(mid_v).position = Point::new(50.0, 80.0); + g.update_edges_for_vertex(mid_v); + + // Both edges incident to this vertex should have updated endpoints + let incident = g.edges_at_vertex(mid_v); + assert_eq!(incident.len(), 2); + + for eid in incident { + let edge = g.edge(eid); + let v0_pos = g.vertex(edge.vertices[0]).position; + let v1_pos = g.vertex(edge.vertices[1]).position; + // One endpoint should be the moved vertex + assert!( + (v0_pos.x - 50.0).abs() < 1.0 && (v0_pos.y - 80.0).abs() < 1.0 + || (v1_pos.x - 50.0).abs() < 1.0 && (v1_pos.y - 80.0).abs() < 1.0, + "one endpoint should be the moved vertex at (50, 80)" + ); + } +} + +#[test] +fn drag_vertex_fill_follows() { + // Build a square, fill it, drag a corner — fill boundary should update + // because the fill references edges, edges reference vertices. + let mut g = VectorGraph::new(); + + let tl = Point::new(0.0, 0.0); + let tr = Point::new(100.0, 0.0); + let br = Point::new(100.0, 100.0); + let bl = Point::new(0.0, 100.0); + + let v_tl = g.alloc_vertex(tl); + let v_tr = g.alloc_vertex(tr); + let v_br = g.alloc_vertex(br); + let v_bl = g.alloc_vertex(bl); + + let e0 = g.alloc_edge(line(tl, tr), v_tl, v_tr, None, None); + let e1 = g.alloc_edge(line(tr, br), v_tr, v_br, None, None); + let e2 = g.alloc_edge(line(br, bl), v_br, v_bl, None, None); + let e3 = g.alloc_edge(line(bl, tl), v_bl, v_tl, None, None); + + let boundary = vec![ + (e0, Direction::Forward), + (e1, Direction::Forward), + (e2, Direction::Forward), + (e3, Direction::Forward), + ]; + let fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + + // Drag top-right corner from (100, 0) to (150, 0) + g.vertex_mut(v_tr).position = Point::new(150.0, 0.0); + g.update_edges_for_vertex(v_tr); + + // The fill's BezPath should reflect the moved vertex + let path = g.fill_to_bezpath(fid); + let bbox = kurbo::Shape::bounding_box(&path); + assert!( + bbox.max_x() > 120.0, + "fill bounding box should extend to the moved vertex, got max_x={:.1}", + bbox.max_x() + ); +} + +// ── Edge editing (# structure, editing a stub) ─────────────────────────── + +#[test] +fn edit_stub_in_hash_only_moves_that_segment() { + // # structure: 2 horizontal + 2 vertical lines + // Grab the top stub of the left vertical line and drag it. + // Only that sub-edge (above the top horizontal) should move. + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + g.insert_stroke(&[line(Point::new(0.0, 30.0), Point::new(100.0, 30.0))], style.clone(), color, 0.5); + g.insert_stroke(&[line(Point::new(0.0, 70.0), Point::new(100.0, 70.0))], style.clone(), color, 0.5); + g.insert_stroke(&[line(Point::new(30.0, 0.0), Point::new(30.0, 100.0))], style.clone(), color, 0.5); + g.insert_stroke(&[line(Point::new(70.0, 0.0), Point::new(70.0, 100.0))], style, color, 0.5); + + // Find the top stub: the edge that goes from (30, 0) to (30, 30) + let stub_edge = g.edges.iter().enumerate().find(|(_, e)| { + if e.deleted { return false; } + let v0 = g.vertex(e.vertices[0]).position; + let v1 = g.vertex(e.vertices[1]).position; + // One endpoint near (30, 0), other near (30, 30) + let has_top = (v0.y < 5.0 && (v0.x - 30.0).abs() < 1.0) + || (v1.y < 5.0 && (v1.x - 30.0).abs() < 1.0); + let has_junction = ((v0.y - 30.0).abs() < 1.0 && (v0.x - 30.0).abs() < 1.0) + || ((v1.y - 30.0).abs() < 1.0 && (v1.x - 30.0).abs() < 1.0); + has_top && has_junction + }).map(|(i, _)| EdgeId(i as u32)); + + assert!(stub_edge.is_some(), "should find the top stub edge (30,0)→(30,30)"); + + // The stub is an independently selectable/editable sub-edge, + // not the full original vertical line. + let stub = g.edge(stub_edge.unwrap()); + let v0_pos = g.vertex(stub.vertices[0]).position; + let v1_pos = g.vertex(stub.vertices[1]).position; + let length = ((v0_pos.x - v1_pos.x).powi(2) + (v0_pos.y - v1_pos.y).powi(2)).sqrt(); + assert!( + (length - 30.0).abs() < 2.0, + "stub should be ~30 units long (from y=0 to y=30), got {length:.1}" + ); +} + +// ── Self-intersection creates new fill regions ─────────────────────────── + +#[test] +fn drag_o_into_figure_eight_splits_fill() { + // Start with a circle-like closed curve (approximated as a square for simplicity), + // fill it, then simulate dragging it into a figure-8 by creating a self-intersection. + let mut g = VectorGraph::new(); + + // Build a diamond shape: top, right, bottom, left + let top = Point::new(50.0, 0.0); + let right = Point::new(100.0, 50.0); + let bottom = Point::new(50.0, 100.0); + let left = Point::new(0.0, 50.0); + + let v_top = g.alloc_vertex(top); + let v_right = g.alloc_vertex(right); + let v_bottom = g.alloc_vertex(bottom); + let v_left = g.alloc_vertex(left); + + let style = StrokeStyle { width: 2.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let e0 = g.alloc_edge(line(top, right), v_top, v_right, Some(style.clone()), Some(color)); + let e1 = g.alloc_edge(line(right, bottom), v_right, v_bottom, Some(style.clone()), Some(color)); + let e2 = g.alloc_edge(line(bottom, left), v_bottom, v_left, Some(style.clone()), Some(color)); + let e3 = g.alloc_edge(line(left, top), v_left, v_top, Some(style), Some(color)); + + let boundary = vec![ + (e0, Direction::Forward), + (e1, Direction::Forward), + (e2, Direction::Forward), + (e3, Direction::Forward), + ]; + let _fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + + // Simulate figure-8: drag top vertex down past center to (50, 70) + // and bottom vertex up past center to (50, 30). + // This causes edges e0/e3 (meeting at top) and e1/e2 (meeting at bottom) + // to cross, creating a self-intersection near the center. + g.vertex_mut(v_top).position = Point::new(50.0, 70.0); + g.update_edges_for_vertex(v_top); + g.vertex_mut(v_bottom).position = Point::new(50.0, 30.0); + g.update_edges_for_vertex(v_bottom); + + // Detect and handle self-intersection. + // Edges e0 ((50,70)→(100,50)) and e2 ((50,30)→(0,50)) now cross. + // Edges e1 ((100,50)→(50,30)) and e3 ((0,50)→(50,70)) now cross. + // After detecting and splitting, the single fill should become two fills + // (the two lobes of the figure-8). + + // TODO: This test documents the expected behavior. The implementation + // needs a "detect self-intersections in fill boundaries" pass that runs + // after vertex edits. For now, we test the expected outcome: + // - The crossing edges should be split at the intersection points + // - The original fill should be split into two fills + // - Both fills should inherit the original color + + // For now, just verify the edges actually cross by checking that + // the diamond is now "inverted" (top below bottom) + assert!( + g.vertex(v_top).position.y > g.vertex(v_bottom).position.y, + "top vertex should now be below bottom vertex (figure-8)" + ); +} + +// ── Control point editing creates new intersections ────────────────────── + +#[test] +fn edit_control_points_creates_intersections() { + // Draw a thin rectangle (0,0)-(100,20) with y-up convention. + // The bottom edge runs along y=0, the top edge along y=20. + // Edit the bottom edge's control points to bow it upward past y=20, + // crossing the top edge in two places. + let mut g = VectorGraph::new(); + + let bl = Point::new(0.0, 0.0); + let br = Point::new(100.0, 0.0); + let tr = Point::new(100.0, 20.0); + let tl = Point::new(0.0, 20.0); + + let v_bl = g.alloc_vertex(bl); + let v_br = g.alloc_vertex(br); + let v_tr = g.alloc_vertex(tr); + let v_tl = g.alloc_vertex(tl); + + let style = StrokeStyle { width: 2.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + + // Bottom edge: (0,0) → (100,0) — the one we'll edit + let e_bottom = g.alloc_edge(line(bl, br), v_bl, v_br, Some(style.clone()), Some(color)); + let e_right = g.alloc_edge(line(br, tr), v_br, v_tr, Some(style.clone()), Some(color)); + // Top edge: (100,20) → (0,20) + let e_top = g.alloc_edge(line(tr, tl), v_tr, v_tl, Some(style.clone()), Some(color)); + let e_left = g.alloc_edge(line(tl, bl), v_tl, v_bl, Some(style), Some(color)); + + let boundary = vec![ + (e_bottom, Direction::Forward), + (e_right, Direction::Forward), + (e_top, Direction::Forward), + (e_left, Direction::Forward), + ]; + let _fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + + // Edit the bottom edge's control points so it bows upward past y=20, + // crossing the top edge in two places. + // Endpoints stay at (0,0) and (100,0), control points go to (0,100) and (100,100). + g.edge_mut(e_bottom).curve = CubicBez::new( + bl, + Point::new(0.0, 100.0), + Point::new(100.0, 100.0), + br, + ); + + // The edited bottom curve now arcs up to ~y=75 at its peak, + // well past the top edge at y=20. It crosses the top edge twice. + // The implementation should: + // 1. Detect that e_bottom and e_top now intersect at 2 points + // 2. Split both edges at the intersection points + // 3. The original fill is split into 3 regions + + // Verify the geometry: sample the edited curve at t=0.5 — should be well above y=20 + let mid = kurbo::ParamCurve::eval(&g.edge(e_bottom).curve, 0.5); + assert!( + mid.y > 20.0, + "edited bottom curve should bow above y=20 (got y={:.1}), crossing the top edge", + mid.y + ); +} + +#[test] +fn edit_curve_into_self_intersection() { + // A single edge that is edited so it crosses itself. + // Start with a straight line, edit control points to create a loop. + let mut g = VectorGraph::new(); + + let p0 = Point::new(0.0, 0.0); + let p1 = Point::new(100.0, 0.0); + let v0 = g.alloc_vertex(p0); + let v1 = g.alloc_vertex(p1); + + let style = StrokeStyle { width: 2.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let eid = g.alloc_edge(line(p0, p1), v0, v1, Some(style), Some(color)); + + // Edit control points to create a loop: + // The curve goes from (0,50), control points pull far left and far right + // at y=100, causing the curve to loop over itself. + g.edge_mut(eid).curve = CubicBez::new( + p0, + Point::new(150.0, 100.0), + Point::new(-50.0, 100.0), + p1, + ); + + // The implementation should detect the self-intersection, split the edge, + // and create a new vertex at the crossing. This forms a loop that is a + // fillable region. + + // Verify the curve actually self-intersects by checking that it + // crosses x=50 more than twice (the loop causes extra crossings). + let mut crossings = 0; + let n = 100; + for i in 0..n { + let t0 = i as f64 / n as f64; + let t1 = (i + 1) as f64 / n as f64; + let x0 = kurbo::ParamCurve::eval(&g.edge(eid).curve, t0).x; + let x1 = kurbo::ParamCurve::eval(&g.edge(eid).curve, t1).x; + if (x0 - 50.0).signum() != (x1 - 50.0).signum() { + crossings += 1; + } + } + assert!( + crossings >= 3, + "edited curve should cross x=50 at least 3 times (self-intersecting), got {crossings}" + ); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/fill.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/fill.rs new file mode 100644 index 0000000..4df3263 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/fill.rs @@ -0,0 +1,399 @@ +//! Paint bucket, fill splitting, fill persistence, and fill merging. + +use super::super::*; +use kurbo::{CubicBez, Point}; + +fn line(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0), + Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0), + b, + ) +} + +fn black_stroke() -> (Option, Option) { + (Some(StrokeStyle { width: 2.0, ..Default::default() }), Some(ShapeColor::rgb(0, 0, 0))) +} + +/// Helper: insert a rectangle as 4 stroke segments, returning the graph. +fn make_rect(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorGraph { + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + let tl = Point::new(x0, y0); + let tr = Point::new(x1, y0); + let br = Point::new(x1, y1); + let bl = Point::new(x0, y1); + g.insert_stroke( + &[line(tl, tr), line(tr, br), line(br, bl), line(bl, tl)], + style, + color, + 0.5, + ); + g +} + +// ── Paint bucket traces boundary and creates fill ──────────────────────── + +#[test] +fn paint_bucket_fills_rectangle() { + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + + // Click inside the rectangle + let fid = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ); + assert!(fid.is_some(), "paint bucket should find and fill the rectangle"); + + let fid = fid.unwrap(); + let fill = g.fill(fid); + assert_eq!(fill.boundary.len(), 4, "rectangle boundary should have 4 edges"); + assert_eq!(fill.color, ShapeColor::rgb(255, 0, 0)); +} + +#[test] +fn paint_bucket_outside_rectangle_returns_none() { + let mut g = make_rect(100.0, 100.0, 200.0, 200.0); + + // Click outside — no enclosed region + let fid = g.paint_bucket( + Point::new(0.0, 0.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ); + assert!(fid.is_none(), "paint bucket outside all curves should return None"); +} + +#[test] +fn paint_bucket_hash_fills_center_only() { + // # structure: the center square should be fillable without including the stubs + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + g.insert_stroke(&[line(Point::new(0.0, 30.0), Point::new(100.0, 30.0))], style.clone(), color, 0.5); + g.insert_stroke(&[line(Point::new(0.0, 70.0), Point::new(100.0, 70.0))], style.clone(), color, 0.5); + g.insert_stroke(&[line(Point::new(30.0, 0.0), Point::new(30.0, 100.0))], style.clone(), color, 0.5); + g.insert_stroke(&[line(Point::new(70.0, 0.0), Point::new(70.0, 100.0))], style, color, 0.5); + + // Click in the center square (50, 50) + let fid = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(0, 0, 255), + FillRule::NonZero, + 0.0, + ); + assert!(fid.is_some(), "should fill the center square of the # pattern"); + + let fid = fid.unwrap(); + let fill = g.fill(fid); + assert_eq!(fill.boundary.len(), 4, "center square should have exactly 4 boundary edges"); + + // Verify the fill region is small (the center square, not the whole #) + let path = g.fill_to_bezpath(fid); + let bbox = kurbo::Shape::bounding_box(&path); + assert!(bbox.width() < 50.0, "fill should be the center square, not the whole structure"); + assert!(bbox.height() < 50.0); +} + +// ── Fill splitting ─────────────────────────────────────────────────────── + +#[test] +fn draw_line_across_fill_splits_it() { + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + + // Fill the rectangle + let fid = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ).expect("should fill"); + + // Draw a horizontal line through the middle, splitting the rectangle + let (style, color) = black_stroke(); + let new_edges = g.insert_stroke( + &[line(Point::new(0.0, 50.0), Point::new(100.0, 50.0))], + style, + color, + 0.5, + ); + + // The new line's endpoints should be at (0, 50) and (100, 50), + // where it intersects the left and right edges of the rectangle. + assert!(!new_edges.is_empty(), "insert_stroke should create at least one edge"); + let first_edge = g.edge(*new_edges.first().unwrap()); + let last_edge = g.edge(*new_edges.last().unwrap()); + let start_pos = g.vertex(first_edge.vertices[0]).position; + let end_pos = g.vertex(last_edge.vertices[1]).position; + assert!( + (start_pos.x - 0.0).abs() < 1.0 && (start_pos.y - 50.0).abs() < 1.0, + "new line should start at (0, 50), got ({:.1}, {:.1})", + start_pos.x, start_pos.y, + ); + assert!( + (end_pos.x - 100.0).abs() < 1.0 && (end_pos.y - 50.0).abs() < 1.0, + "new line should end at (100, 50), got ({:.1}, {:.1})", + end_pos.x, end_pos.y, + ); + + // The original fill should have been split into two fills + let live_fills: Vec<_> = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted) + .collect(); + assert_eq!(live_fills.len(), 2, "drawing a line across a fill should split it into 2"); + + // Both fills should inherit the original color + for (_, fill) in &live_fills { + assert_eq!(fill.color, ShapeColor::rgb(255, 0, 0)); + } +} + +#[test] +fn draw_line_not_through_fill_does_not_split() { + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + + let _fid = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ).expect("should fill"); + + // Draw a line outside the rectangle — should not affect the fill + let (style, color) = black_stroke(); + g.insert_stroke( + &[line(Point::new(200.0, 0.0), Point::new(200.0, 100.0))], + style, + color, + 0.5, + ); + + let live_fills = g.fills.iter().filter(|f| !f.deleted).count(); + assert_eq!(live_fills, 1, "line outside fill should not split it"); +} + +#[test] +fn draw_line_partially_across_fill_does_not_split() { + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + + let fid = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ).expect("should fill"); + + // Draw a line that enters the fill but doesn't reach the other side: + // (0, 50) → (50, 50) — starts on the left edge, ends in the middle + let (style, color) = black_stroke(); + g.insert_stroke( + &[line(Point::new(0.0, 50.0), Point::new(50.0, 50.0))], + style, + color, + 0.5, + ); + + // The fill should NOT be split — the line only touches one boundary edge, + // not two. It's a spur (dead end) inside the fill. + let live_fills = g.fills.iter().filter(|f| !f.deleted).count(); + assert_eq!(live_fills, 1, "line partially across fill should not split it"); + + // The fill should still reference a valid closed boundary + let fill = g.fill(fid); + assert!(!fill.deleted); +} + +// ── Shared edges and concentric fills ──────────────────────────────────── + +#[test] +fn inner_square_reusing_edge_not_filled() { + // Outer square (0,0)-(100,100), a spur from (0,50)→(50,50), + // then an inner square (50,50)-(75,75). The spur connects the inner + // square to the outer boundary. Fill the outer square — the inner + // square should NOT be filled (it's a separate enclosed region). + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + let (style, color) = black_stroke(); + + // Spur: (0,50) → (50,50) + g.insert_stroke( + &[line(Point::new(0.0, 50.0), Point::new(50.0, 50.0))], + style.clone(), color, 0.5, + ); + + // Inner square: (50,50) → (75,50) → (75,75) → (50,75) → (50,50) + g.insert_stroke( + &[ + line(Point::new(50.0, 50.0), Point::new(75.0, 50.0)), + line(Point::new(75.0, 50.0), Point::new(75.0, 75.0)), + line(Point::new(75.0, 75.0), Point::new(50.0, 75.0)), + line(Point::new(50.0, 75.0), Point::new(50.0, 50.0)), + ], + style, color, 0.5, + ); + + // Fill outside the inner square but inside the outer square + let fid = g.paint_bucket( + Point::new(25.0, 25.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ).expect("should fill the outer region"); + + // The fill should NOT cover the inner square's interior + let path = g.fill_to_bezpath(fid); + // Point inside the inner square should be outside the fill path + assert_eq!( + kurbo::Shape::winding(&path, Point::new(62.0, 62.0)), + 0, + "inner square interior should not be included in the outer fill" + ); + // Point in the outer region should be inside the fill path + assert_ne!( + kurbo::Shape::winding(&path, Point::new(25.0, 25.0)), + 0, + "outer region should be inside the fill" + ); +} + +#[test] +fn concentric_squares_fill_has_hole() { + // Outer square (0,0)-(100,100), inner square (25,25)-(75,75). + // No connecting edge — the two squares share no vertices. + // Filling the outer region should produce a fill with a hole + // (the inner square subtracts from the outer). + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + let (style, color) = black_stroke(); + + // Inner square, entirely inside the outer one + g.insert_stroke( + &[ + line(Point::new(25.0, 25.0), Point::new(75.0, 25.0)), + line(Point::new(75.0, 25.0), Point::new(75.0, 75.0)), + line(Point::new(75.0, 75.0), Point::new(25.0, 75.0)), + line(Point::new(25.0, 75.0), Point::new(25.0, 25.0)), + ], + style, color, 0.5, + ); + + // Fill between the two squares (click in the gap between them) + let fid = g.paint_bucket( + Point::new(10.0, 10.0), + ShapeColor::rgb(0, 255, 0), + FillRule::NonZero, + 0.0, + ).expect("should fill the annular region"); + + let path = g.fill_to_bezpath(fid); + + // Point in the gap (between squares) should be inside the fill + assert_ne!( + kurbo::Shape::winding(&path, Point::new(10.0, 10.0)), + 0, + "gap between squares should be filled" + ); + + // Point inside the inner square should NOT be filled + assert_eq!( + kurbo::Shape::winding(&path, Point::new(50.0, 50.0)), + 0, + "inner square interior should be a hole in the fill" + ); +} + +// ── Fill persistence through edge deletion ─────────────────────────────── + +#[test] +fn fill_persists_when_edge_deleted() { + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + + let fid = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ).expect("should fill"); + + // Delete one edge of the rectangle + let boundary_edge = g.fill(fid).boundary[0].0; + g.delete_edge_by_user(boundary_edge); + + // Fill should still exist and still have the same boundary + assert!(!g.fill(fid).deleted, "fill should persist when its boundary edge is deleted"); + assert_eq!(g.fill(fid).boundary.len(), 4, "fill boundary should be unchanged"); + + // The deleted edge should now be invisible but still exist + assert!(!g.edge(boundary_edge).deleted); + assert!(!g.edge_is_visible(boundary_edge)); +} + +#[test] +fn deleting_fill_then_gc_removes_invisible_edges() { + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + + let fid = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ).expect("should fill"); + + // Make all edges invisible (user deleted the strokes) + let boundary_edges: Vec = g.fill(fid).boundary.iter().map(|(e, _)| *e).collect(); + for &eid in &boundary_edges { + g.make_edge_invisible(eid); + } + + // Edges should still exist because fill references them + for &eid in &boundary_edges { + assert!(!g.edge(eid).deleted); + } + + // Now delete the fill + g.free_fill(fid); + + // GC should remove the invisible, now-unreferenced edges + g.gc_invisible_edges(); + for &eid in &boundary_edges { + assert!(g.edge(eid).deleted, "invisible edge should be GC'd after fill deleted"); + } +} + +// ── Fill merging ───────────────────────────────────────────────────────── + +#[test] +fn deleting_dividing_edge_merges_fills() { + let mut g = make_rect(0.0, 0.0, 100.0, 100.0); + + // Fill the rectangle + let _fid = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ).expect("should fill"); + + // Draw a horizontal line through the middle to split the fill + let (style, color) = black_stroke(); + let new_edges = g.insert_stroke( + &[line(Point::new(0.0, 50.0), Point::new(100.0, 50.0))], + style, + color, + 0.5, + ); + + let live_fills_before = g.fills.iter().filter(|f| !f.deleted).count(); + assert_eq!(live_fills_before, 2); + + // Delete the dividing line — the two fills should merge back into one + // (The dividing edge endpoints are on the fill boundaries, making this detectable) + for eid in new_edges { + g.delete_edge_by_user(eid); + } + + let live_fills_after = g.fills.iter().filter(|f| !f.deleted).count(); + assert_eq!(live_fills_after, 1, "deleting the dividing edge should merge the two fills"); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/gap_close.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/gap_close.rs new file mode 100644 index 0000000..3c76293 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/gap_close.rs @@ -0,0 +1,271 @@ +//! Gap tolerance fill tracing: invisible edges bridging small gaps, +//! mid-curve gap closing, and area-limiting behavior. + +use super::super::*; +use kurbo::{CubicBez, Point}; + +fn line(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0), + Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0), + b, + ) +} + +fn black_stroke() -> (Option, Option) { + (Some(StrokeStyle { width: 2.0, ..Default::default() }), Some(ShapeColor::rgb(0, 0, 0))) +} + +// ── Endpoint gap closing ───────────────────────────────────────────────── + +#[test] +fn gap_close_bridges_small_endpoint_gap() { + // Three sides of a rectangle with a small gap at the fourth corner. + // Without gap tolerance: no enclosed region. + // With gap tolerance: the gap is bridged by an invisible edge. + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + let tl = Point::new(0.0, 100.0); + let tr = Point::new(100.0, 100.0); + let br = Point::new(100.0, 0.0); + let bl = Point::new(0.0, 0.0); + let bl_gap = Point::new(3.0, 0.0); // 3px gap from true bottom-left + + // Three complete sides + one side that stops 3px short + g.insert_stroke(&[line(tl, tr)], style.clone(), color, 0.5); + g.insert_stroke(&[line(tr, br)], style.clone(), color, 0.5); + g.insert_stroke(&[line(br, bl_gap)], style.clone(), color, 0.5); + g.insert_stroke(&[line(bl, tl)], style, color, 0.5); + + // Without gap tolerance: should fail to find an enclosed region + let no_gap = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 0.0, + ); + assert!(no_gap.is_none(), "should not find enclosed region with zero gap tolerance"); + + // With gap tolerance of 5px: should bridge the 3px gap + let with_gap = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 5.0, + ); + assert!(with_gap.is_some(), "should bridge the 3px gap with 5px tolerance"); + + // The bridge should be a real invisible edge in the graph + let fid = with_gap.unwrap(); + let fill = g.fill(fid); + let has_invisible_boundary_edge = fill.boundary.iter().any(|(eid, _)| { + !g.edge_is_visible(*eid) + }); + assert!(has_invisible_boundary_edge, "gap-close should create an invisible edge"); +} + +#[test] +fn gap_close_does_not_bridge_large_gap() { + // Same as above but with a 20px gap — should not bridge with 5px tolerance. + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + let tl = Point::new(0.0, 100.0); + let tr = Point::new(100.0, 100.0); + let br = Point::new(100.0, 0.0); + let bl = Point::new(0.0, 0.0); + let bl_gap = Point::new(20.0, 0.0); // 20px gap + + g.insert_stroke(&[line(tl, tr)], style.clone(), color, 0.5); + g.insert_stroke(&[line(tr, br)], style.clone(), color, 0.5); + g.insert_stroke(&[line(br, bl_gap)], style.clone(), color, 0.5); + g.insert_stroke(&[line(bl, tl)], style, color, 0.5); + + let result = g.paint_bucket( + Point::new(50.0, 50.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 5.0, + ); + assert!(result.is_none(), "should not bridge a 20px gap with 5px tolerance"); +} + +// ── Mid-curve gap closing: )( pattern ──────────────────────────────────── + +#[test] +fn gap_close_mid_curve_parentheses() { + // Two opposing arcs forming a )( shape, with caps at top and bottom + // so the ends are closed. The closest approach is at the midpoints + // of the arcs (~5px gap). Gap-close should bridge there, and filling + // on one side should only fill that half — not the full eye shape. + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + // Left arc: ) shape — endpoints at (40, 0) and (40, 100), bowing right to x≈55 + g.insert_stroke( + &[CubicBez::new( + Point::new(40.0, 0.0), + Point::new(60.0, 0.0), + Point::new(60.0, 100.0), + Point::new(40.0, 100.0), + )], + style.clone(), color, 0.5, + ); + + // Right arc: ( shape — endpoints at (70, 0) and (70, 100), bowing left to x≈59 + // (must stay right of left arc's max x≈55 to avoid crossing) + g.insert_stroke( + &[CubicBez::new( + Point::new(70.0, 0.0), + Point::new(55.0, 0.0), + Point::new(55.0, 100.0), + Point::new(70.0, 100.0), + )], + style.clone(), color, 0.5, + ); + + // Cap the top: (40, 0) → (70, 0) + g.insert_stroke(&[line(Point::new(40.0, 0.0), Point::new(70.0, 0.0))], style.clone(), color, 0.5); + + // Cap the bottom: (40, 100) → (70, 100) + g.insert_stroke(&[line(Point::new(40.0, 100.0), Point::new(70.0, 100.0))], style, color, 0.5); + + // The full shape is an eye/lens. The mid-curve gap (~3.75px at y≈50) + // divides it into left and right halves. + // With gap tolerance of 10px, clicking in the LEFT half should only + // fill the left half — the gap-close bridge at the midpoints acts + // as a dividing edge. + // The bridge divides the eye horizontally at y≈50. + // The eye interior is between the two arcs (at y=25, roughly x=53..60). + // Click between the arcs in the upper half. + let fid = g.paint_bucket( + Point::new(57.0, 25.0), // between arcs, upper half + ShapeColor::rgb(0, 0, 255), + FillRule::NonZero, + 10.0, + ); + assert!(fid.is_some(), "should bridge mid-curve gap and fill one half"); + + let fid = fid.unwrap(); + let path = g.fill_to_bezpath(fid); + let bbox = kurbo::Shape::bounding_box(&path); + + // The fill should cover only one half of the eye shape. + // The full eye spans y=0..100, so one half should be roughly y=0..50. + assert!( + bbox.height() < 60.0, + "fill should only cover one half of the eye, got height={:.1}", + bbox.height() + ); + + // The other half should NOT be filled + assert_eq!( + kurbo::Shape::winding(&path, Point::new(57.0, 75.0)), + 0, + "other half of the eye should not be filled" + ); +} + +// ── Acute corner: gap-close should NOT cut across ──────────────────────── + +#[test] +fn gap_close_does_not_shortcut_acute_corner() { + // Two edges meeting at a sharp acute angle at vertex (50, 0). + // Near the vertex, the edges are close together, but they are connected — + // gap-close should NOT bridge between them. + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + // Two lines meeting at a sharp angle at (50, 0) + // Left arm: (0, 50) → (50, 0) + // Right arm: (50, 0) → (100, 50) + g.insert_stroke( + &[ + line(Point::new(0.0, 50.0), Point::new(50.0, 0.0)), + line(Point::new(50.0, 0.0), Point::new(100.0, 50.0)), + ], + style.clone(), color, 0.5, + ); + + // Close off the bottom to form a triangle + g.insert_stroke( + &[line(Point::new(0.0, 50.0), Point::new(100.0, 50.0))], + style, color, 0.5, + ); + + // Fill the triangle with generous gap tolerance — the fill should go all + // the way into the acute corner at (50, 0), not shortcut across it. + let fid = g.paint_bucket( + Point::new(50.0, 30.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 10.0, + ).expect("should fill the triangle"); + + // The fill should reach the apex at (50, 0) + let path = g.fill_to_bezpath(fid); + let bbox = kurbo::Shape::bounding_box(&path); + assert!( + bbox.min_y() < 2.0, + "fill should reach the apex near y=0, got min_y={:.1}", + bbox.min_y() + ); +} + +// ── Gap tolerance as area limiter ──────────────────────────────────────── + +#[test] +fn gap_close_prefers_smallest_enclosing_region() { + // A large rectangle with a small rectangle inside it. + // The small rectangle has a gap. With gap tolerance, the user clicks + // inside the small rectangle — should fill the small rectangle, + // NOT the large one (even though the large one is also reachable + // through the gap). + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + // Large rectangle: (0, 0) → (200, 200) + g.insert_stroke( + &[ + line(Point::new(0.0, 0.0), Point::new(200.0, 0.0)), + line(Point::new(200.0, 0.0), Point::new(200.0, 200.0)), + line(Point::new(200.0, 200.0), Point::new(0.0, 200.0)), + line(Point::new(0.0, 200.0), Point::new(0.0, 0.0)), + ], + style.clone(), color, 0.5, + ); + + // Small rectangle inside: (80, 80) → (120, 120), with a 3px gap + g.insert_stroke( + &[ + line(Point::new(80.0, 80.0), Point::new(120.0, 80.0)), + line(Point::new(120.0, 80.0), Point::new(120.0, 120.0)), + line(Point::new(120.0, 120.0), Point::new(83.0, 120.0)), // stops 3px short + ], + style.clone(), color, 0.5, + ); + g.insert_stroke( + &[line(Point::new(80.0, 120.0), Point::new(80.0, 80.0))], + style, color, 0.5, + ); + + // Click inside the small rectangle with gap tolerance + let fid = g.paint_bucket( + Point::new(100.0, 100.0), + ShapeColor::rgb(255, 0, 0), + FillRule::NonZero, + 5.0, + ).expect("should fill with gap tolerance"); + + // The fill should be the small rectangle, not the large one + let path = g.fill_to_bezpath(fid); + let bbox = kurbo::Shape::bounding_box(&path); + assert!( + bbox.width() < 60.0 && bbox.height() < 60.0, + "should fill the small rectangle (~40x40), not the large one (~200x200), got {:.0}x{:.0}", + bbox.width(), + bbox.height() + ); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs new file mode 100644 index 0000000..429c951 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs @@ -0,0 +1,10 @@ +#[cfg(test)] +mod basic; +#[cfg(test)] +mod stroke; +#[cfg(test)] +mod fill; +#[cfg(test)] +mod editing; +#[cfg(test)] +mod gap_close; diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/stroke.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/stroke.rs new file mode 100644 index 0000000..2997ca2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/stroke.rs @@ -0,0 +1,237 @@ +//! Stroke insertion with intersection detection and curve splitting. + +use super::super::*; +use kurbo::{CubicBez, Point}; + +fn line(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0), + Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0), + b, + ) +} + +fn black_stroke() -> (Option, Option) { + (Some(StrokeStyle { width: 2.0, ..Default::default() }), Some(ShapeColor::rgb(0, 0, 0))) +} + +// ── Single stroke ──────────────────────────────────────────────────────── + +#[test] +fn insert_single_line_creates_edge() { + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + let edges = g.insert_stroke( + &[line(Point::new(0.0, 0.0), Point::new(100.0, 0.0))], + style, + color, + 0.5, + ); + assert_eq!(edges.len(), 1); + assert!(g.edge_is_visible(edges[0])); + + // Should have 2 vertices (endpoints) + let live_verts = g.vertices.iter().filter(|v| !v.deleted).count(); + assert_eq!(live_verts, 2); +} + +#[test] +fn insert_multi_segment_stroke_creates_chain() { + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + let segments = vec![ + line(Point::new(0.0, 0.0), Point::new(50.0, 0.0)), + line(Point::new(50.0, 0.0), Point::new(100.0, 50.0)), + line(Point::new(100.0, 50.0), Point::new(100.0, 100.0)), + ]; + let edges = g.insert_stroke(&segments, style, color, 0.5); + assert_eq!(edges.len(), 3); + + // Should have 4 vertices (start + 2 intermediate + end) + let live_verts = g.vertices.iter().filter(|v| !v.deleted).count(); + assert_eq!(live_verts, 4); +} + +#[test] +fn insert_stroke_snaps_to_existing_vertex() { + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + // First stroke: (0,0) → (100,0) + g.insert_stroke( + &[line(Point::new(0.0, 0.0), Point::new(100.0, 0.0))], + style.clone(), + color, + 0.5, + ); + + // Second stroke starts very close to (100,0) — should snap, not create new vertex + g.insert_stroke( + &[line(Point::new(100.2, 0.1), Point::new(100.0, 100.0))], + style, + color, + 0.5, + ); + + // Should have 3 vertices, not 4 (the near-endpoint was snapped) + let live_verts = g.vertices.iter().filter(|v| !v.deleted).count(); + assert_eq!(live_verts, 3); +} + +// ── Intersection splitting ─────────────────────────────────────────────── + +#[test] +fn crossing_strokes_creates_intersection_vertex() { + // Two perpendicular lines forming a + + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + // Horizontal: (0, 50) → (100, 50) + g.insert_stroke( + &[line(Point::new(0.0, 50.0), Point::new(100.0, 50.0))], + style.clone(), + color, + 0.5, + ); + + // Vertical: (50, 0) → (50, 100) — crosses the horizontal at (50, 50) + g.insert_stroke( + &[line(Point::new(50.0, 0.0), Point::new(50.0, 100.0))], + style, + color, + 0.5, + ); + + // The horizontal should have been split into 2 edges + // The vertical should be split into 2 edges + // Total: 4 edges, 5 vertices (4 endpoints + 1 intersection) + let live_edges = g.edges.iter().filter(|e| !e.deleted).count(); + let live_verts = g.vertices.iter().filter(|v| !v.deleted).count(); + assert_eq!(live_edges, 4, "two lines crossing = 4 sub-edges"); + assert_eq!(live_verts, 5, "4 endpoints + 1 intersection vertex"); + + // The intersection vertex should be near (50, 50) + let intersection_v = g.vertices.iter().find(|v| { + !v.deleted + && (v.position.x - 50.0).abs() < 1.0 + && (v.position.y - 50.0).abs() < 1.0 + }); + assert!(intersection_v.is_some(), "should have a vertex near (50, 50)"); +} + +#[test] +fn hash_structure_four_crossing_edges() { + // Four lines creating a # pattern: + // Two horizontal, two vertical — 4 intersection points + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + // Horizontal lines + g.insert_stroke( + &[line(Point::new(0.0, 30.0), Point::new(100.0, 30.0))], + style.clone(), color, 0.5, + ); + g.insert_stroke( + &[line(Point::new(0.0, 70.0), Point::new(100.0, 70.0))], + style.clone(), color, 0.5, + ); + + // Vertical lines — each crosses both horizontals + g.insert_stroke( + &[line(Point::new(30.0, 0.0), Point::new(30.0, 100.0))], + style.clone(), color, 0.5, + ); + g.insert_stroke( + &[line(Point::new(70.0, 0.0), Point::new(70.0, 100.0))], + style, color, 0.5, + ); + + // 4 intersection vertices + 8 endpoints = 12 vertices + // Each of the 4 original lines is split into 3 sub-edges = 12 edges + let live_verts = g.vertices.iter().filter(|v| !v.deleted).count(); + let live_edges = g.edges.iter().filter(|e| !e.deleted).count(); + assert_eq!(live_verts, 12); + assert_eq!(live_edges, 12); +} + +#[test] +fn self_intersecting_stroke_splits() { + // A curve that crosses itself (figure-8 like). + // We approximate with line segments forming an X. + let mut g = VectorGraph::new(); + let (style, color) = black_stroke(); + + let segments = vec![ + line(Point::new(0.0, 0.0), Point::new(100.0, 100.0)), + line(Point::new(100.0, 100.0), Point::new(100.0, 0.0)), + line(Point::new(100.0, 0.0), Point::new(0.0, 100.0)), + ]; + + let edges = g.insert_stroke(&segments, style, color, 0.5); + + // The first segment (0,0)→(100,100) and third segment (100,0)→(0,100) + // cross near (50, 50). This splits both into 2 sub-edges each, + // plus the middle segment (100,100)→(100,0) is untouched. + // Total: 2 + 1 + 2 = 5 edges, 4 corners + 1 self-intersection = 5 vertices. + let live_verts = g.vertices.iter().filter(|v| !v.deleted).count(); + let live_edges = g.edges.iter().filter(|e| !e.deleted).count(); + assert_eq!( + live_verts, 5, + "should have 5 vertices (4 corners + 1 self-intersection), got {live_verts}" + ); + assert_eq!( + live_edges, 5, + "should have 5 edges (2 split + 1 unsplit + 2 split), got {live_edges}" + ); +} + +// ── Edge splitting preserves fills ─────────────────────────────────────── + +#[test] +fn split_edge_updates_fill_boundary() { + let mut g = VectorGraph::new(); + + // Build a square manually + let tl = Point::new(0.0, 0.0); + let tr = Point::new(100.0, 0.0); + let br = Point::new(100.0, 100.0); + let bl = Point::new(0.0, 100.0); + + let v_tl = g.alloc_vertex(tl); + let v_tr = g.alloc_vertex(tr); + let v_br = g.alloc_vertex(br); + let v_bl = g.alloc_vertex(bl); + + let e_top = g.alloc_edge(line(tl, tr), v_tl, v_tr, None, None); + let e_right = g.alloc_edge(line(tr, br), v_tr, v_br, None, None); + let e_bottom = g.alloc_edge(line(br, bl), v_br, v_bl, None, None); + let e_left = g.alloc_edge(line(bl, tl), v_bl, v_tl, None, None); + + let boundary = vec![ + (e_top, Direction::Forward), + (e_right, Direction::Forward), + (e_bottom, Direction::Forward), + (e_left, Direction::Forward), + ]; + let fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + + // Split the top edge at t=0.5 + let (_mid_v, sub_a, sub_b) = g.split_edge(e_top, 0.5); + + // The fill should now reference sub_a and sub_b instead of e_top + let fill = g.fill(fid); + assert_eq!(fill.boundary.len(), 5, "boundary should grow from 4 to 5 edges"); + assert!( + fill.boundary.iter().any(|(eid, _)| *eid == sub_a), + "fill should reference first sub-edge" + ); + assert!( + fill.boundary.iter().any(|(eid, _)| *eid == sub_b), + "fill should reference second sub-edge" + ); + assert!( + !fill.boundary.iter().any(|(eid, _)| *eid == e_top), + "fill should no longer reference the original edge" + ); +}