diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel.rs b/lightningbeam-ui/lightningbeam-core/src/dcel.rs index 5224f75..b5a76dd 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel.rs @@ -5,7 +5,7 @@ //! maintained such that wherever two strokes intersect there is a vertex. use crate::shape::{FillRule, ShapeColor, StrokeStyle}; -use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point}; +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Shape as KurboShape}; use rstar::{PointDistance, RTree, RTreeObject, AABB}; use serde::{Deserialize, Serialize}; use std::fmt; @@ -774,6 +774,274 @@ impl Dcel { path } + // ----------------------------------------------------------------------- + // Region queries + // ----------------------------------------------------------------------- + + /// Return all non-deleted, non-unbounded faces whose interior lies inside `region`. + /// + /// For each face, a representative interior point is found by offsetting from + /// a boundary edge midpoint along the inward-facing normal (the face lies to + /// the left of its boundary half-edges). This works for concave/crescent faces + /// where a simple centroid could land outside the face. + // ----------------------------------------------------------------------- + // Region extraction (split DCEL by vertex classification) + // ----------------------------------------------------------------------- + + /// Extract the portion of the DCEL inside a closed region path. + /// + /// After inserting the region boundary via `insert_stroke()`, all crossing + /// edges have been split at intersection points. This method classifies + /// every vertex as INSIDE, OUTSIDE, or BOUNDARY (on the region path), + /// then: + /// - In a clone (`extracted`): removes edges with any OUTSIDE endpoint + /// - In `self`: removes edges with any INSIDE endpoint + /// + /// Boundary edges (both endpoints on the boundary) are kept in **both**. + /// Returns the extracted (inside) DCEL. + pub fn extract_region(&mut self, region: &BezPath, epsilon: f64) -> Dcel { + // Step 1: Classify every non-deleted vertex + #[derive(Clone, Copy, PartialEq, Eq)] + enum VClass { Inside, Outside, Boundary } + + let classifications: Vec = self.vertices.iter().map(|v| { + if v.deleted { + return VClass::Outside; // doesn't matter, won't be referenced + } + // Check distance to region path + let pos = v.position; + if Self::point_distance_to_path(pos, region) < epsilon { + VClass::Boundary + } else if region.winding(pos) != 0 { + VClass::Inside + } else { + VClass::Outside + } + }).collect(); + + // Step 2: Clone self → extracted + let mut extracted = self.clone(); + + // Step 3: In extracted, remove edges where either endpoint is OUTSIDE + let edges_to_remove_from_extracted: Vec = extracted.edges.iter().enumerate() + .filter_map(|(i, edge)| { + if edge.deleted { return None; } + let edge_id = EdgeId(i as u32); + let [he_fwd, he_bwd] = edge.half_edges; + let v1 = extracted.half_edges[he_fwd.idx()].origin; + let v2 = extracted.half_edges[he_bwd.idx()].origin; + if classifications[v1.idx()] == VClass::Outside + || classifications[v2.idx()] == VClass::Outside + { + Some(edge_id) + } else { + None + } + }) + .collect(); + + for edge_id in edges_to_remove_from_extracted { + if !extracted.edges[edge_id.idx()].deleted { + extracted.remove_edge(edge_id); + } + } + + // Step 4: In self, remove edges where either endpoint is INSIDE + let edges_to_remove_from_self: Vec = self.edges.iter().enumerate() + .filter_map(|(i, edge)| { + if edge.deleted { return None; } + let edge_id = EdgeId(i as u32); + let [he_fwd, he_bwd] = edge.half_edges; + let v1 = self.half_edges[he_fwd.idx()].origin; + let v2 = self.half_edges[he_bwd.idx()].origin; + if classifications[v1.idx()] == VClass::Inside + || classifications[v2.idx()] == VClass::Inside + { + Some(edge_id) + } else { + None + } + }) + .collect(); + + for edge_id in edges_to_remove_from_self { + if !self.edges[edge_id.idx()].deleted { + self.remove_edge(edge_id); + } + } + + extracted + } + + /// Propagate fill properties from a snapshot DCEL to faces that lost them + /// during `insert_stroke` (e.g., when region boundary edges split a filled face + /// but the new sub-face didn't inherit the fill). + /// + /// For each unfilled face, finds a robust interior sample point (centroid with + /// winding-check, or inward-normal offset fallback), then looks it up in the + /// snapshot to determine what fill it should have. + pub fn propagate_fills(&mut self, snapshot: &Dcel) { + use kurbo::ParamCurveDeriv; + + for i in 1..self.faces.len() { + let face = &self.faces[i]; + if face.deleted || face.outer_half_edge.is_none() { + continue; + } + // Skip faces that already have fill + if face.fill_color.is_some() || face.image_fill.is_some() { + continue; + } + + let face_id = FaceId(i as u32); + let boundary = self.face_boundary(face_id); + if boundary.is_empty() { + continue; + } + + let face_path = self.halfedges_to_bezpath(&boundary); + + // Strategy 1: Use the centroid of boundary vertices. For convex faces + // (common after region splitting), this is guaranteed to be interior. + let mut cx = 0.0; + let mut cy = 0.0; + let mut n_verts = 0; + for &he_id in &boundary { + let he = &self.half_edges[he_id.idx()]; + let v = &self.vertices[he.origin.idx()]; + cx += v.position.x; + cy += v.position.y; + n_verts += 1; + } + let mut sample_point = None; + if n_verts > 0 { + let centroid = Point::new(cx / n_verts as f64, cy / n_verts as f64); + if face_path.winding(centroid) != 0 { + sample_point = Some(centroid); + } + } + + // Strategy 2: Inward-normal offset from edge midpoints (fallback for + // non-convex faces where the centroid falls outside). + if sample_point.is_none() { + let epsilon = 0.5; + for &he_id in &boundary { + let he = &self.half_edges[he_id.idx()]; + let edge = &self.edges[he.edge.idx()]; + let is_forward = edge.half_edges[0] == he_id; + let curve = if is_forward { + edge.curve + } else { + CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0) + }; + + let mid = curve.eval(0.5); + let tangent = curve.deriv().eval(0.5); + let len = (tangent.x * tangent.x + tangent.y * tangent.y).sqrt(); + if len < 1e-12 { + continue; + } + let nx = tangent.y / len; + let ny = -tangent.x / len; + let candidate = Point::new(mid.x + nx * epsilon, mid.y + ny * epsilon); + if face_path.winding(candidate) != 0 { + sample_point = Some(candidate); + break; + } + } + } + + let sample = match sample_point { + Some(p) => p, + None => continue, + }; + + // Look up which face this interior point was in the snapshot + let snap_face_id = snapshot.find_face_containing_point(sample); + if snap_face_id.0 == 0 { + continue; + } + let snap_face = &snapshot.faces[snap_face_id.idx()]; + if snap_face.fill_color.is_some() || snap_face.image_fill.is_some() { + self.faces[i].fill_color = snap_face.fill_color.clone(); + self.faces[i].image_fill = snap_face.image_fill; + self.faces[i].fill_rule = snap_face.fill_rule; + } + } + } + + /// Compute the minimum distance from a point to a BezPath (treated as a polyline/curve). + fn point_distance_to_path(point: Point, path: &BezPath) -> f64 { + use kurbo::PathEl; + + let mut min_dist = f64::MAX; + let mut current = Point::ZERO; + let mut subpath_start = Point::ZERO; + + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + current = p; + subpath_start = p; + } + PathEl::LineTo(p) => { + let d = Self::point_to_line_segment_dist(point, current, p); + if d < min_dist { min_dist = d; } + current = p; + } + PathEl::QuadTo(cp, p) => { + // Approximate as cubic + let cp1 = Point::new( + current.x + 2.0 / 3.0 * (cp.x - current.x), + current.y + 2.0 / 3.0 * (cp.y - current.y), + ); + let cp2 = Point::new( + p.x + 2.0 / 3.0 * (cp.x - p.x), + p.y + 2.0 / 3.0 * (cp.y - p.y), + ); + let cubic = CubicBez::new(current, cp1, cp2, p); + let nearest = cubic.nearest(point, 0.5); + let d = nearest.distance_sq.sqrt(); + if d < min_dist { min_dist = d; } + current = p; + } + PathEl::CurveTo(cp1, cp2, p) => { + let cubic = CubicBez::new(current, cp1, cp2, p); + let nearest = cubic.nearest(point, 0.5); + let d = nearest.distance_sq.sqrt(); + if d < min_dist { min_dist = d; } + current = p; + } + PathEl::ClosePath => { + if (current.x - subpath_start.x).abs() > 1e-10 + || (current.y - subpath_start.y).abs() > 1e-10 + { + let d = Self::point_to_line_segment_dist(point, current, subpath_start); + if d < min_dist { min_dist = d; } + } + current = subpath_start; + } + } + } + + min_dist + } + + /// Distance from a point to a line segment. + fn point_to_line_segment_dist(point: Point, a: Point, b: Point) -> f64 { + let dx = b.x - a.x; + let dy = b.y - a.y; + let len_sq = dx * dx + dy * dy; + if len_sq < 1e-20 { + return ((point.x - a.x).powi(2) + (point.y - a.y).powi(2)).sqrt(); + } + let t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / len_sq; + let t = t.clamp(0.0, 1.0); + let proj_x = a.x + t * dx; + let proj_y = a.y + t * dy; + ((point.x - proj_x).powi(2) + (point.y - proj_y).powi(2)).sqrt() + } + // ----------------------------------------------------------------------- // Validation (debug) // ----------------------------------------------------------------------- @@ -4664,4 +4932,85 @@ mod tests { dump_all_faces(&d, "After stroke 4"); } + + /// Reproduce the user's test case: rectangle (100,100)-(200,200), + /// region select (0,0)-(150,150). The overlap corner face should be + /// detected as inside the region. + #[test] + fn test_extract_region_rectangle_corner() { + use crate::region_select::line_to_cubic; + use kurbo::{Line, Shape as _}; + + let mut dcel = Dcel::new(); + + // Draw a rectangle from (100,100) to (200,200) as 4 line strokes + let rect_sides = [ + Line::new(Point::new(100.0, 100.0), Point::new(200.0, 100.0)), + Line::new(Point::new(200.0, 100.0), Point::new(200.0, 200.0)), + Line::new(Point::new(200.0, 200.0), Point::new(100.0, 200.0)), + Line::new(Point::new(100.0, 200.0), Point::new(100.0, 100.0)), + ]; + for side in &rect_sides { + let seg = line_to_cubic(side); + dcel.insert_stroke(&[seg], None, None, 1.0); + } + + // Set fill on the rectangle face (simulating paint bucket) + let rect_face = dcel.find_face_containing_point(Point::new(150.0, 150.0)); + assert!(rect_face.0 != 0, "rectangle face should be bounded"); + dcel.face_mut(rect_face).fill_color = Some(ShapeColor::new(255, 0, 0, 255)); + + // Region select rectangle from (0,0) to (150,150) — overlaps top-left corner + let region_sides = [ + Line::new(Point::new(0.0, 0.0), Point::new(150.0, 0.0)), + Line::new(Point::new(150.0, 0.0), Point::new(150.0, 150.0)), + Line::new(Point::new(150.0, 150.0), Point::new(0.0, 150.0)), + Line::new(Point::new(0.0, 150.0), Point::new(0.0, 0.0)), + ]; + let region_segments: Vec = region_sides.iter().map(|l| line_to_cubic(l)).collect(); + let snapshot = dcel.clone(); + dcel.insert_stroke(®ion_segments, None, None, 1.0); + + // Build the region path for extract_region + let mut region_path = BezPath::new(); + region_path.move_to(Point::new(0.0, 0.0)); + region_path.line_to(Point::new(150.0, 0.0)); + region_path.line_to(Point::new(150.0, 150.0)); + region_path.line_to(Point::new(0.0, 150.0)); + region_path.close_path(); + + // Extract, then propagate fills on extracted only (remainder keeps + // its fills from the original data — no propagation needed there). + let mut extracted = dcel.extract_region(®ion_path, 1.0); + extracted.propagate_fills(&snapshot); + + // The extracted DCEL should have at least one face with fill (the corner overlap) + let extracted_filled_faces: Vec<_> = extracted.faces.iter().enumerate() + .filter(|(i, f)| !f.deleted && *i > 0 && f.fill_color.is_some()) + .collect(); + assert!( + !extracted_filled_faces.is_empty(), + "Extracted DCEL should have at least one filled face (the corner overlap)" + ); + + // The original DCEL (remainder) should still have filled faces (the L-shaped remainder) + let remainder_filled_faces: Vec<_> = dcel.faces.iter().enumerate() + .filter(|(i, f)| !f.deleted && *i > 0 && f.fill_color.is_some()) + .collect(); + assert!( + !remainder_filled_faces.is_empty(), + "Remainder DCEL should have at least one filled face (L-shaped remainder)" + ); + + // The empty-space face in the remainder (outside the original rectangle) + // should NOT have fill — verify no spurious fill propagation + let point_outside_rect = Point::new(50.0, 50.0); + let face_at_outside = dcel.find_face_containing_point(point_outside_rect); + if face_at_outside.0 != 0 && !dcel.face(face_at_outside).deleted { + assert!( + dcel.face(face_at_outside).fill_color.is_none(), + "Face at (50,50) should NOT have fill — it's outside the original rectangle" + ); + } + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs new file mode 100644 index 0000000..14e580f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs @@ -0,0 +1,569 @@ +//! Doubly-Connected Edge List (DCEL) for planar subdivision vector drawing. +//! +//! Each vector layer keyframe stores a DCEL representing a Flash-style planar +//! subdivision. Strokes live on edges, fills live on faces, and the topology is +//! maintained such that wherever two strokes intersect there is a vertex. +//! +//! Half-edges leaving a vertex are maintained in sorted CCW order. This enables +//! efficient face detection by ray-casting to the nearest edge and walking CCW. + +pub mod topology; +pub mod query; +pub mod stroke; +pub mod region; + +use crate::shape::{FillRule, ShapeColor, StrokeStyle}; +use kurbo::{CubicBez, Point}; +use rstar::{PointDistance, RTree, RTreeObject, AABB}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +// --------------------------------------------------------------------------- +// 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!(HalfEdgeId); +define_id!(EdgeId); +define_id!(FaceId); + +// --------------------------------------------------------------------------- +// Core structs +// --------------------------------------------------------------------------- + +/// A vertex in the DCEL. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Vertex { + pub position: Point, + /// One outgoing half-edge (any one; iteration via twin.next gives the CCW fan). + /// NONE if the vertex is isolated (no edges). + pub outgoing: HalfEdgeId, + #[serde(default)] + pub deleted: bool, +} + +/// A half-edge in the DCEL. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HalfEdge { + pub origin: VertexId, + pub twin: HalfEdgeId, + /// Next half-edge around the face (CCW). + pub next: HalfEdgeId, + /// Previous half-edge around the face (CCW). + pub prev: HalfEdgeId, + /// Face to the left of this half-edge. + pub face: FaceId, + /// Parent edge (shared between this half-edge and its twin). + pub edge: EdgeId, + #[serde(default)] + pub deleted: bool, +} + +/// Geometric and style data for an edge (shared by the two half-edges). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EdgeData { + /// The two half-edges: [forward, backward]. + /// Forward goes from curve.p0 to curve.p3. + pub half_edges: [HalfEdgeId; 2], + pub curve: CubicBez, + pub stroke_style: Option, + pub stroke_color: Option, + #[serde(default)] + pub deleted: bool, +} + +/// A face (region) in the DCEL. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Face { + /// One half-edge on the outer boundary. NONE for the unbounded face (face 0). + pub outer_half_edge: HalfEdgeId, + /// Half-edges on inner boundary cycles (holes). + pub inner_half_edges: Vec, + pub fill_color: Option, + pub image_fill: Option, + pub fill_rule: FillRule, + #[serde(default)] + pub deleted: bool, +} + +// --------------------------------------------------------------------------- +// Spatial index for vertex snapping +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub struct VertexEntry { + pub id: VertexId, + pub position: [f64; 2], +} + +impl RTreeObject for VertexEntry { + type Envelope = AABB<[f64; 2]>; + fn envelope(&self) -> Self::Envelope { + AABB::from_point(self.position) + } +} + +impl PointDistance for VertexEntry { + fn distance_2(&self, point: &[f64; 2]) -> f64 { + let dx = self.position[0] - point[0]; + let dy = self.position[1] - point[1]; + dx * dx + dy * dy + } +} + +// --------------------------------------------------------------------------- +// Debug recorder +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Default)] +pub struct DebugRecorder { + pub strokes: Vec>, + pub paint_points: Vec, +} + +impl DebugRecorder { + pub fn record_stroke(&mut self, segments: &[CubicBez]) { + self.strokes.push(segments.to_vec()); + } + + pub fn record_paint(&mut self, point: Point) { + self.paint_points.push(point); + } + + pub fn dump_test(&self, name: &str) { + eprintln!(" #[test]"); + eprintln!(" fn {name}() {{"); + eprintln!(" let mut dcel = Dcel::new();"); + eprintln!(); + for (i, stroke) in self.strokes.iter().enumerate() { + eprintln!(" // Stroke {i}"); + eprintln!(" dcel.insert_stroke(&["); + for seg in stroke { + eprintln!( + " CubicBez::new(Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1})),", + seg.p0.x, seg.p0.y, seg.p1.x, seg.p1.y, + seg.p2.x, seg.p2.y, seg.p3.x, seg.p3.y, + ); + } + eprintln!(" ], None, None, 5.0);"); + eprintln!(); + } + for (i, pt) in self.paint_points.iter().enumerate() { + eprintln!(" // Paint {i}"); + eprintln!( + " let _f{i} = dcel.find_face_at_point(Point::new({:.1}, {:.1}));", + pt.x, pt.y + ); + } + eprintln!(" }}"); + } + + pub fn dump_and_reset(&mut self, name: &str) { + self.dump_test(name); + self.strokes.clear(); + self.paint_points.clear(); + } +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Default snap epsilon in document coordinate units. +pub const DEFAULT_SNAP_EPSILON: f64 = 0.5; + +// --------------------------------------------------------------------------- +// DCEL container +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Dcel { + pub vertices: Vec, + pub half_edges: Vec, + pub edges: Vec, + pub faces: Vec, + + free_vertices: Vec, + free_half_edges: Vec, + free_edges: Vec, + free_faces: Vec, + + #[serde(skip)] + vertex_rtree: Option>, + + #[serde(skip)] + pub debug_recorder: Option, +} + +impl Default for Dcel { + fn default() -> Self { + Self::new() + } +} + +impl Dcel { + /// Create a new empty DCEL with just the unbounded outer face (face 0). + pub fn new() -> Self { + let unbounded = Face { + outer_half_edge: HalfEdgeId::NONE, + inner_half_edges: Vec::new(), + fill_color: None, + image_fill: None, + fill_rule: FillRule::NonZero, + deleted: false, + }; + let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() { + Some(DebugRecorder::default()) + } else { + None + }; + Dcel { + vertices: Vec::new(), + half_edges: Vec::new(), + edges: Vec::new(), + faces: vec![unbounded], + free_vertices: Vec::new(), + free_half_edges: Vec::new(), + free_edges: Vec::new(), + free_faces: Vec::new(), + vertex_rtree: None, + debug_recorder, + } + } + + // ----------------------------------------------------------------------- + // Debug recording + // ----------------------------------------------------------------------- + + pub fn set_recording(&mut self, enabled: bool) { + if enabled { + self.debug_recorder.get_or_insert_with(DebugRecorder::default); + } else { + self.debug_recorder = None; + } + } + + pub fn is_recording(&self) -> bool { + self.debug_recorder.is_some() + } + + pub fn dump_recorded_test(&mut self, name: &str) { + if let Some(ref mut rec) = self.debug_recorder { + rec.dump_and_reset(name); + } + } + + pub fn record_paint_point(&mut self, point: Point) { + if let Some(ref mut rec) = self.debug_recorder { + rec.record_paint(point); + } + } + + // ----------------------------------------------------------------------- + // Allocation + // ----------------------------------------------------------------------- + + pub fn alloc_vertex(&mut self, position: Point) -> VertexId { + let id = if let Some(idx) = self.free_vertices.pop() { + let id = VertexId(idx); + self.vertices[id.idx()] = Vertex { + position, + outgoing: HalfEdgeId::NONE, + deleted: false, + }; + id + } else { + let id = VertexId(self.vertices.len() as u32); + self.vertices.push(Vertex { + position, + outgoing: HalfEdgeId::NONE, + deleted: false, + }); + id + }; + self.vertex_rtree = None; + id + } + + pub fn alloc_half_edge_pair(&mut self) -> (HalfEdgeId, HalfEdgeId) { + let tombstone = HalfEdge { + origin: VertexId::NONE, + twin: HalfEdgeId::NONE, + next: HalfEdgeId::NONE, + prev: HalfEdgeId::NONE, + face: FaceId::NONE, + edge: EdgeId::NONE, + deleted: false, + }; + + let alloc_one = |dcel: &mut Dcel| -> HalfEdgeId { + if let Some(idx) = dcel.free_half_edges.pop() { + let id = HalfEdgeId(idx); + dcel.half_edges[id.idx()] = tombstone.clone(); + id + } else { + let id = HalfEdgeId(dcel.half_edges.len() as u32); + dcel.half_edges.push(tombstone.clone()); + id + } + }; + + let a = alloc_one(self); + let b = alloc_one(self); + self.half_edges[a.idx()].twin = b; + self.half_edges[b.idx()].twin = a; + (a, b) + } + + pub fn alloc_edge(&mut self, curve: CubicBez) -> EdgeId { + let data = EdgeData { + half_edges: [HalfEdgeId::NONE, HalfEdgeId::NONE], + curve, + stroke_style: None, + stroke_color: None, + deleted: false, + }; + if let Some(idx) = self.free_edges.pop() { + let id = EdgeId(idx); + self.edges[id.idx()] = data; + id + } else { + let id = EdgeId(self.edges.len() as u32); + self.edges.push(data); + id + } + } + + pub fn alloc_face(&mut self) -> FaceId { + let face = Face { + outer_half_edge: HalfEdgeId::NONE, + inner_half_edges: Vec::new(), + fill_color: None, + image_fill: None, + fill_rule: FillRule::NonZero, + deleted: false, + }; + if let Some(idx) = self.free_faces.pop() { + let id = FaceId(idx); + self.faces[id.idx()] = face; + id + } else { + let id = FaceId(self.faces.len() as u32); + self.faces.push(face); + 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); + self.vertex_rtree = None; + } + + pub fn free_half_edge(&mut self, id: HalfEdgeId) { + debug_assert!(!id.is_none()); + self.half_edges[id.idx()].deleted = true; + self.free_half_edges.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_face(&mut self, id: FaceId) { + debug_assert!(!id.is_none()); + debug_assert!(id.0 != 0, "cannot free the unbounded face"); + self.faces[id.idx()].deleted = true; + self.free_faces.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 half_edge(&self, id: HalfEdgeId) -> &HalfEdge { + &self.half_edges[id.idx()] + } + + #[inline] + pub fn half_edge_mut(&mut self, id: HalfEdgeId) -> &mut HalfEdge { + &mut self.half_edges[id.idx()] + } + + #[inline] + pub fn edge(&self, id: EdgeId) -> &EdgeData { + &self.edges[id.idx()] + } + + #[inline] + pub fn edge_mut(&mut self, id: EdgeId) -> &mut EdgeData { + &mut self.edges[id.idx()] + } + + #[inline] + pub fn face(&self, id: FaceId) -> &Face { + &self.faces[id.idx()] + } + + #[inline] + pub fn face_mut(&mut self, id: FaceId) -> &mut Face { + &mut self.faces[id.idx()] + } + + /// Destination vertex of a half-edge (origin of its twin). + #[inline] + pub fn half_edge_dest(&self, he: HalfEdgeId) -> VertexId { + let twin = self.half_edge(he).twin; + self.half_edge(twin).origin + } +} + +// --------------------------------------------------------------------------- +// Bezier utilities +// --------------------------------------------------------------------------- + +/// Split a cubic bezier at parameter t using de Casteljau's algorithm. +pub 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 subsegment of a cubic bezier for parameter range [t0, t1]. +pub fn subsegment_cubic(c: CubicBez, t0: f64, t1: f64) -> CubicBez { + if (t0).abs() < 1e-10 && (t1 - 1.0).abs() < 1e-10 { + return c; + } + if (t0).abs() < 1e-10 { + subdivide_cubic(c, t1).0 + } else if (t1 - 1.0).abs() < 1e-10 { + subdivide_cubic(c, t0).1 + } else { + let (_, upper) = subdivide_cubic(c, t0); + let remapped_t1 = (t1 - t0) / (1.0 - t0); + subdivide_cubic(upper, remapped_t1).0 + } +} + +#[inline] +pub 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) +} + +/// Convert a `BezPath` into a list of sub-paths, each a `Vec`. +pub fn bezpath_to_cubic_segments(path: &kurbo::BezPath) -> Vec> { + use kurbo::PathEl; + + let mut result: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut subpath_start = Point::ZERO; + let mut cursor = Point::ZERO; + + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + if !current.is_empty() { + result.push(std::mem::take(&mut current)); + } + subpath_start = p; + cursor = p; + } + PathEl::LineTo(p) => { + let c1 = lerp_point(cursor, p, 1.0 / 3.0); + let c2 = lerp_point(cursor, p, 2.0 / 3.0); + current.push(CubicBez::new(cursor, c1, c2, p)); + cursor = p; + } + PathEl::QuadTo(p1, p2) => { + let cp1 = Point::new( + cursor.x + (2.0 / 3.0) * (p1.x - cursor.x), + cursor.y + (2.0 / 3.0) * (p1.y - cursor.y), + ); + let cp2 = Point::new( + p2.x + (2.0 / 3.0) * (p1.x - p2.x), + p2.y + (2.0 / 3.0) * (p1.y - p2.y), + ); + current.push(CubicBez::new(cursor, cp1, cp2, p2)); + cursor = p2; + } + PathEl::CurveTo(p1, p2, p3) => { + current.push(CubicBez::new(cursor, p1, p2, p3)); + cursor = p3; + } + PathEl::ClosePath => { + let dist = ((cursor.x - subpath_start.x).powi(2) + + (cursor.y - subpath_start.y).powi(2)) + .sqrt(); + if dist > 1e-9 { + let c1 = lerp_point(cursor, subpath_start, 1.0 / 3.0); + let c2 = lerp_point(cursor, subpath_start, 2.0 / 3.0); + current.push(CubicBez::new(cursor, c1, c2, subpath_start)); + } + cursor = subpath_start; + if !current.is_empty() { + result.push(std::mem::take(&mut current)); + } + } + } + } + if !current.is_empty() { + result.push(current); + } + result +} diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs new file mode 100644 index 0000000..b70a7b3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs @@ -0,0 +1,381 @@ +//! Queries, iteration, and BezPath construction for the DCEL. + +use super::{Dcel, EdgeId, FaceId, HalfEdgeId, VertexEntry, VertexId}; +use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, Point}; +use rstar::{PointDistance, RTree}; +use std::collections::HashSet; + +/// Result of a face-at-point query. +pub struct FaceQuery { + /// The face currently assigned to the cycle (may be F0 if no face was created). + pub face: FaceId, + /// A half-edge on the enclosing cycle. Walk via `next` to traverse. + pub cycle_he: HalfEdgeId, +} + +impl Dcel { + // ------------------------------------------------------------------- + // Iteration + // ------------------------------------------------------------------- + + /// Walk the half-edge cycle starting at `start`, returning all half-edges. + pub fn walk_cycle(&self, start: HalfEdgeId) -> Vec { + let mut result = vec![start]; + let mut cur = self.half_edges[start.idx()].next; + let mut steps = 0; + while cur != start { + result.push(cur); + cur = self.half_edges[cur.idx()].next; + steps += 1; + debug_assert!(steps < 100_000, "infinite cycle in walk_cycle"); + } + result + } + + /// Get all half-edges on a face's outer boundary. + pub fn face_boundary(&self, face_id: FaceId) -> Vec { + let ohe = self.faces[face_id.idx()].outer_half_edge; + if ohe.is_none() { + return Vec::new(); + } + self.walk_cycle(ohe) + } + + /// Get all outgoing half-edges from a vertex in CCW order. + pub fn vertex_outgoing(&self, vertex_id: VertexId) -> Vec { + let start = self.vertices[vertex_id.idx()].outgoing; + if start.is_none() { + return Vec::new(); + } + let mut result = vec![start]; + let twin = self.half_edges[start.idx()].twin; + let mut cur = self.half_edges[twin.idx()].next; + let mut steps = 0; + while cur != start { + result.push(cur); + let twin = self.half_edges[cur.idx()].twin; + cur = self.half_edges[twin.idx()].next; + steps += 1; + debug_assert!(steps < 100_000, "infinite fan in vertex_outgoing"); + } + result + } + + // ------------------------------------------------------------------- + // Face detection + // ------------------------------------------------------------------- + + /// Find the enclosing face/cycle for a point. + /// + /// Algorithm: + /// 1. Find the nearest edge to `point` + /// 2. Pick the half-edge with `point` on its left side (cross product of tangent) + /// 3. Walk that half-edge's cycle — this is the innermost boundary enclosing `point` + /// 4. Return the cycle's face (which may be F0 if no face has been created yet) + /// along with a half-edge on the cycle so the caller can create a face if needed + /// + /// Returns F0 with NONE cycle_he if there are no edges. + pub fn find_face_at_point(&self, point: Point) -> FaceQuery { + let mut best: Option<(EdgeId, f64, f64)> = None; + + for (i, edge) in self.edges.iter().enumerate() { + if edge.deleted { + continue; + } + let nearest = edge.curve.nearest(point, 0.5); + if best.is_none() || nearest.distance_sq < best.unwrap().1 { + best = Some((EdgeId(i as u32), nearest.distance_sq, nearest.t)); + } + } + + let Some((edge_id, _, t)) = best else { + return FaceQuery { + face: FaceId(0), + cycle_he: HalfEdgeId::NONE, + }; + }; + + let edge = &self.edges[edge_id.idx()]; + + // Tangent via finite difference (clamped to valid range) + let t_lo = (t - 0.001).max(0.0); + let t_hi = (t + 0.001).min(1.0); + let p_lo = edge.curve.eval(t_lo); + let p_hi = edge.curve.eval(t_hi); + let tan_x = p_hi.x - p_lo.x; + let tan_y = p_hi.y - p_lo.y; + + let curve_pt = edge.curve.eval(t); + let to_pt_x = point.x - curve_pt.x; + let to_pt_y = point.y - curve_pt.y; + let cross = tan_x * to_pt_y - tan_y * to_pt_x; + + // cross > 0: point is to the left of the forward half-edge + let he = if cross >= 0.0 { + edge.half_edges[0] + } else { + edge.half_edges[1] + }; + + // Walk the cycle to find the actual face + let face = self.half_edges[he.idx()].face; + + FaceQuery { + face, + cycle_he: he, + } + } + + /// Convenience: just return the FaceId (backward-compatible). + pub fn find_face_containing_point(&self, point: Point) -> FaceId { + self.find_face_at_point(point).face + } + + // ------------------------------------------------------------------- + // Spatial index (vertex snapping) + // ------------------------------------------------------------------- + + pub fn rebuild_spatial_index(&mut self) { + let entries: Vec = self + .vertices + .iter() + .enumerate() + .filter(|(_, v)| !v.deleted) + .map(|(i, v)| VertexEntry { + id: VertexId(i as u32), + position: [v.position.x, v.position.y], + }) + .collect(); + self.vertex_rtree = Some(RTree::bulk_load(entries)); + } + + pub fn ensure_spatial_index(&mut self) { + if self.vertex_rtree.is_none() { + self.rebuild_spatial_index(); + } + } + + pub fn snap_vertex(&mut self, point: Point, epsilon: f64) -> Option { + self.ensure_spatial_index(); + let tree = self.vertex_rtree.as_ref().unwrap(); + let query = [point.x, point.y]; + let nearest = tree.nearest_neighbor(&query)?; + let dist_sq = nearest.distance_2(&query); + if dist_sq <= epsilon * epsilon { + Some(nearest.id) + } else { + None + } + } + + // ------------------------------------------------------------------- + // BezPath construction for rendering + // ------------------------------------------------------------------- + + /// Raw bezpath from a face's outer boundary cycle. + pub fn face_to_bezpath(&self, face_id: FaceId) -> BezPath { + let cycle = self.face_boundary(face_id); + self.cycle_to_bezpath(&cycle) + } + + /// Build a BezPath from a cycle of half-edges. + pub fn cycle_to_bezpath(&self, cycle: &[HalfEdgeId]) -> BezPath { + let mut path = BezPath::new(); + if cycle.is_empty() { + return path; + } + + let first_he = &self.half_edges[cycle[0].idx()]; + let first_pos = self.vertices[first_he.origin.idx()].position; + path.move_to(first_pos); + + for &he_id in cycle { + let he = &self.half_edges[he_id.idx()]; + let edge = &self.edges[he.edge.idx()]; + if he_id == edge.half_edges[0] { + path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3); + } else { + path.curve_to(edge.curve.p2, edge.curve.p1, edge.curve.p0); + } + } + path.close_path(); + path + } + + /// Bezpath with spur edges stripped (for fill rendering). + pub fn face_to_bezpath_stripped(&self, face_id: FaceId) -> BezPath { + let cycle = self.face_boundary(face_id); + let stripped = self.strip_spurs(&cycle); + self.cycle_to_bezpath(&stripped) + } + + /// Bezpath with outer boundary + reversed holes (for fill rendering). + pub fn face_to_bezpath_with_holes(&self, face_id: FaceId) -> BezPath { + let face = &self.faces[face_id.idx()]; + let mut path = self.face_to_bezpath_stripped(face_id); + + let inner_hes: Vec = face.inner_half_edges.clone(); + for inner_he in inner_hes { + if inner_he.is_none() || self.half_edges[inner_he.idx()].deleted { + continue; + } + let inner_cycle = self.walk_cycle(inner_he); + let stripped = self.strip_spurs(&inner_cycle); + if stripped.is_empty() { + continue; + } + // Append hole reversed so winding rule cuts it out + let reversed = self.cycle_to_bezpath_reversed(&stripped); + for el in reversed.elements() { + match *el { + PathEl::MoveTo(p) => path.move_to(p), + PathEl::LineTo(p) => path.line_to(p), + PathEl::QuadTo(p1, p2) => path.quad_to(p1, p2), + PathEl::CurveTo(p1, p2, p3) => path.curve_to(p1, p2, p3), + PathEl::ClosePath => path.close_path(), + } + } + } + path + } + + /// Build a BezPath traversing a cycle in reverse direction. + fn cycle_to_bezpath_reversed(&self, cycle: &[HalfEdgeId]) -> BezPath { + let mut path = BezPath::new(); + if cycle.is_empty() { + return path; + } + + // Start from the destination of the last half-edge + let last_dest = self.half_edge_dest(*cycle.last().unwrap()); + let start_pos = self.vertices[last_dest.idx()].position; + path.move_to(start_pos); + + for &he_id in cycle.iter().rev() { + let he = &self.half_edges[he_id.idx()]; + let edge = &self.edges[he.edge.idx()]; + if he_id == edge.half_edges[0] { + // Was forward, now traversing backward + path.curve_to(edge.curve.p2, edge.curve.p1, edge.curve.p0); + } else { + // Was backward, now traversing forward + path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3); + } + } + path.close_path(); + path + } + + /// Strip spur (antenna) edges from a cycle. + /// + /// A spur traverses an edge forward then immediately backward (or vice versa). + /// Stack-based: push half-edges; if top shares the same edge as the new one, + /// pop (cancel the pair). + fn strip_spurs(&self, cycle: &[HalfEdgeId]) -> Vec { + if cycle.is_empty() { + return Vec::new(); + } + + let mut stack: Vec = Vec::with_capacity(cycle.len()); + for &he in cycle { + if let Some(&top) = stack.last() { + if self.half_edges[top.idx()].edge == self.half_edges[he.idx()].edge { + stack.pop(); + continue; + } + } + stack.push(he); + } + + // Handle wrap-around spurs at the seam + while stack.len() >= 2 { + let first_edge = self.half_edges[stack[0].idx()].edge; + let last_edge = self.half_edges[stack.last().unwrap().idx()].edge; + if first_edge == last_edge { + stack.remove(0); + stack.pop(); + } else { + break; + } + } + + stack + } + + // ------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------- + + /// Validate DCEL invariants. Panics with a descriptive message on failure. + pub fn validate(&self) { + // 1. Twin symmetry + for (i, he) in self.half_edges.iter().enumerate() { + if he.deleted { continue; } + let id = HalfEdgeId(i as u32); + let twin = he.twin; + assert!(!twin.is_none(), "HE{i} has NONE twin"); + assert!(!self.half_edges[twin.idx()].deleted, "HE{i} twin is deleted"); + assert_eq!(self.half_edges[twin.idx()].twin, id, "HE{i} twin symmetry broken"); + } + + // 2. Next/prev consistency + for (i, he) in self.half_edges.iter().enumerate() { + if he.deleted { continue; } + let id = HalfEdgeId(i as u32); + assert!(!he.next.is_none(), "HE{i} has NONE next"); + assert!(!he.prev.is_none(), "HE{i} has NONE prev"); + assert_eq!(self.half_edges[he.next.idx()].prev, id, "HE{i} next.prev != self"); + assert_eq!(self.half_edges[he.prev.idx()].next, id, "HE{i} prev.next != self"); + } + + // 3. Face boundary consistency: all half-edges in a cycle share the same face + let mut visited = HashSet::new(); + for (i, he) in self.half_edges.iter().enumerate() { + if he.deleted { continue; } + let id = HalfEdgeId(i as u32); + if visited.contains(&id) { continue; } + let cycle = self.walk_cycle(id); + let face = he.face; + for &cid in &cycle { + assert_eq!( + self.half_edges[cid.idx()].face, face, + "HE{} face {:?} != cycle leader HE{i} face {:?}", + cid.0, self.half_edges[cid.idx()].face, face + ); + visited.insert(cid); + } + } + + // 4. Vertex outgoing consistency + for (i, v) in self.vertices.iter().enumerate() { + if v.deleted || v.outgoing.is_none() { continue; } + let he = &self.half_edges[v.outgoing.idx()]; + assert!(!he.deleted, "V{i} outgoing points to deleted HE"); + assert_eq!(he.origin, VertexId(i as u32), "V{i} outgoing.origin mismatch"); + } + + // 5. Edge ↔ half-edge consistency + for (i, edge) in self.edges.iter().enumerate() { + if edge.deleted { continue; } + let [fwd, bwd] = edge.half_edges; + assert!(!fwd.is_none() && !bwd.is_none(), "E{i} has NONE half-edges"); + assert_eq!(self.half_edges[fwd.idx()].edge, EdgeId(i as u32), "E{i} fwd.edge mismatch"); + assert_eq!(self.half_edges[bwd.idx()].edge, EdgeId(i as u32), "E{i} bwd.edge mismatch"); + assert_eq!(self.half_edges[fwd.idx()].twin, bwd, "E{i} fwd.twin != bwd"); + } + + // 6. Curve endpoint ↔ vertex position + for (i, edge) in self.edges.iter().enumerate() { + if edge.deleted { continue; } + let [fwd, bwd] = edge.half_edges; + let v_start = self.half_edges[fwd.idx()].origin; + let v_end = self.half_edges[bwd.idx()].origin; + let p_start = self.vertices[v_start.idx()].position; + let p_end = self.vertices[v_end.idx()].position; + let d0 = (p_start.x - edge.curve.p0.x).powi(2) + (p_start.y - edge.curve.p0.y).powi(2); + let d3 = (p_end.x - edge.curve.p3.x).powi(2) + (p_end.y - edge.curve.p3.y).powi(2); + assert!(d0 < 1.0, "E{i} p0 far from V{}", v_start.0); + assert!(d3 < 1.0, "E{i} p3 far from V{}", v_end.0); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/region.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/region.rs new file mode 100644 index 0000000..522ead8 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/region.rs @@ -0,0 +1,272 @@ +//! Region extraction from the DCEL. +//! +//! `extract_region` splits a DCEL along a closed boundary path: the inside +//! portion is returned as a new DCEL, the outside portion stays in `self`. +//! Boundary edges are kept in both. +//! +//! Vertex classification is deterministic: boundary vertices are known from +//! inserting the region stroke, all others are classified by winding number. +//! Faces are classified by which vertices they touch — no sampling needed. + +use super::{Dcel, EdgeId, FaceId, VertexId}; +use kurbo::{BezPath, Point, Shape}; + +/// Vertex classification relative to the region boundary. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum VClass { + Inside, + Outside, + Boundary, +} + +impl Dcel { + /// Extract the sub-DCEL inside a closed region path. + /// + /// The caller must have already inserted the region boundary via + /// `insert_stroke`, passing the resulting vertex IDs as `boundary_vertices`. + /// All other vertices are classified by winding number against `region`. + /// + /// Returns the extracted (inside) DCEL. `self` is modified to contain + /// only the outside portion. Boundary edges appear in both. + pub fn extract_region( + &mut self, + region: &BezPath, + boundary_vertices: &[VertexId], + ) -> Dcel { + let classifications = self.classify_vertices(region, boundary_vertices); + + // Clone → extracted + let mut extracted = self.clone(); + + // In extracted: remove edges where either endpoint is Outside + let to_remove: Vec = extracted + .edges + .iter() + .enumerate() + .filter_map(|(i, edge)| { + if edge.deleted { return None; } + let [fwd, bwd] = edge.half_edges; + let v1 = extracted.half_edges[fwd.idx()].origin; + let v2 = extracted.half_edges[bwd.idx()].origin; + if classifications[v1.idx()] == VClass::Outside + || classifications[v2.idx()] == VClass::Outside + { + Some(EdgeId(i as u32)) + } else { + None + } + }) + .collect(); + + for edge_id in to_remove { + if !extracted.edges[edge_id.idx()].deleted { + extracted.remove_edge(edge_id); + } + } + + // In self: remove edges where either endpoint is Inside + let to_remove: Vec = self + .edges + .iter() + .enumerate() + .filter_map(|(i, edge)| { + if edge.deleted { return None; } + let [fwd, bwd] = edge.half_edges; + let v1 = self.half_edges[fwd.idx()].origin; + let v2 = self.half_edges[bwd.idx()].origin; + if classifications[v1.idx()] == VClass::Inside + || classifications[v2.idx()] == VClass::Inside + { + Some(EdgeId(i as u32)) + } else { + None + } + }) + .collect(); + + for edge_id in to_remove { + if !self.edges[edge_id.idx()].deleted { + self.remove_edge(edge_id); + } + } + + extracted + } + + /// Classify every vertex as Inside, Outside, or Boundary. + fn classify_vertices( + &self, + region: &BezPath, + boundary_vertices: &[VertexId], + ) -> Vec { + self.vertices + .iter() + .enumerate() + .map(|(i, v)| { + if v.deleted { + return VClass::Outside; + } + let vid = VertexId(i as u32); + if boundary_vertices.contains(&vid) { + VClass::Boundary + } else if region.winding(v.position) != 0 { + VClass::Inside + } else { + VClass::Outside + } + }) + .collect() + } + + /// Copy fill properties from `snapshot` to faces in `self` that lost + /// them when the region boundary split filled faces. + /// + /// For each unfilled face, walks its boundary to find an Inside vertex, + /// then looks up the snapshot face at that vertex's position to inherit + /// the fill. No sampling heuristic — vertex positions are exact. + pub fn propagate_fills( + &mut self, + snapshot: &Dcel, + region: &BezPath, + boundary_vertices: &[VertexId], + ) { + let classifications = self.classify_vertices(region, boundary_vertices); + + for i in 1..self.faces.len() { + let face = &self.faces[i]; + if face.deleted || face.outer_half_edge.is_none() { + continue; + } + if face.fill_color.is_some() || face.image_fill.is_some() { + continue; + } + + let face_id = FaceId(i as u32); + let boundary = self.face_boundary(face_id); + + // Find an inside vertex on this face's boundary + let probe = boundary.iter().find_map(|&he_id| { + let vid = self.half_edges[he_id.idx()].origin; + if classifications[vid.idx()] == VClass::Inside { + Some(self.vertices[vid.idx()].position) + } else { + None + } + }); + + let probe_point = match probe { + Some(p) => p, + None => continue, // face has no inside vertices — skip + }; + + let snap_face_id = snapshot.find_face_containing_point(probe_point); + if snap_face_id.0 == 0 { + continue; + } + let snap_face = &snapshot.faces[snap_face_id.idx()]; + if snap_face.fill_color.is_some() || snap_face.image_fill.is_some() { + self.faces[i].fill_color = snap_face.fill_color.clone(); + self.faces[i].image_fill = snap_face.image_fill; + self.faces[i].fill_rule = snap_face.fill_rule; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::{CubicBez, Point}; + + fn line_cubic(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, + ) + } + + #[test] + fn extract_region_basic() { + let mut dcel = Dcel::new(); + + // Two horizontal lines crossing the region boundary: + // line A at y=30: (0,30) → (100,30) + // line B at y=70: (0,70) → (100,70) + let a0 = Point::new(0.0, 30.0); + let a1 = Point::new(100.0, 30.0); + let b0 = Point::new(0.0, 70.0); + let b1 = Point::new(100.0, 70.0); + + let va0 = dcel.alloc_vertex(a0); + let va1 = dcel.alloc_vertex(a1); + let vb0 = dcel.alloc_vertex(b0); + let vb1 = dcel.alloc_vertex(b1); + + dcel.insert_edge(va0, va1, FaceId(0), line_cubic(a0, a1)); + dcel.insert_edge(vb0, vb1, FaceId(0), line_cubic(b0, b1)); + + assert_eq!(dcel.edges.iter().filter(|e| !e.deleted).count(), 2); + + // Region covers the left half: x ∈ [-10, 50] + let mut region = BezPath::new(); + region.move_to(Point::new(-10.0, -10.0)); + region.line_to(Point::new(50.0, -10.0)); + region.line_to(Point::new(50.0, 110.0)); + region.line_to(Point::new(-10.0, 110.0)); + region.close_path(); + + // va0, vb0 are inside (x=0), va1, vb1 are outside (x=100) + // No boundary vertices in this simple test + let extracted = dcel.extract_region(®ion, &[]); + + // Both edges have one inside and one outside endpoint, + // so both are removed from both halves + let self_edges = dcel.edges.iter().filter(|e| !e.deleted).count(); + let ext_edges = extracted.edges.iter().filter(|e| !e.deleted).count(); + assert_eq!(self_edges, 0, "edges span boundary → removed from self"); + assert_eq!(ext_edges, 0, "edges span boundary → removed from extracted"); + } + + #[test] + fn extract_region_with_boundary_vertices() { + let mut dcel = Dcel::new(); + + // Build a horizontal line that will be split by the region boundary. + // We simulate what happens after insert_stroke splits it: + // left piece: (0,50) → (50,50) [inside → boundary] + // right piece: (50,50) → (100,50) [boundary → outside] + let p_left = Point::new(0.0, 50.0); + let p_mid = Point::new(50.0, 50.0); + let p_right = Point::new(100.0, 50.0); + + let v_left = dcel.alloc_vertex(p_left); + let v_mid = dcel.alloc_vertex(p_mid); + let v_right = dcel.alloc_vertex(p_right); + + dcel.insert_edge(v_left, v_mid, FaceId(0), line_cubic(p_left, p_mid)); + dcel.insert_edge(v_mid, v_right, FaceId(0), line_cubic(p_mid, p_right)); + + // Region: left half (x < 50) + let mut region = BezPath::new(); + region.move_to(Point::new(-10.0, -10.0)); + region.line_to(Point::new(50.0, -10.0)); + region.line_to(Point::new(50.0, 110.0)); + region.line_to(Point::new(-10.0, 110.0)); + region.close_path(); + + // v_mid is on the boundary + let extracted = dcel.extract_region(®ion, &[v_mid]); + + // Left edge: inside → boundary → kept in extracted + // Right edge: boundary → outside → kept in self + let ext_edges = extracted.edges.iter().filter(|e| !e.deleted).count(); + let self_edges = dcel.edges.iter().filter(|e| !e.deleted).count(); + assert_eq!(ext_edges, 1, "extracted should have left edge"); + assert_eq!(self_edges, 1, "self should have right edge"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs new file mode 100644 index 0000000..d7e4a03 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs @@ -0,0 +1,897 @@ +//! High-level stroke insertion into the DCEL. +//! +//! `insert_stroke` is the main entry point for the Draw tool. +//! +//! For each new stroke segment, we find intersections with existing edges and +//! immediately split both curves at the intersection point, sharing a single +//! vertex. This avoids the problem where batch-processing gives slightly +//! different intersection positions for the same crossing. + +use super::{ + subsegment_cubic, Dcel, EdgeId, FaceId, HalfEdgeId, VertexId, DEFAULT_SNAP_EPSILON, +}; +use crate::curve_intersections::{find_curve_intersections, Intersection}; +use crate::shape::{ShapeColor, StrokeStyle}; +use kurbo::{CubicBez, ParamCurve, Point}; + +pub struct InsertStrokeResult { + pub new_vertices: Vec, + pub new_edges: Vec, + pub split_edges: Vec<(EdgeId, f64, VertexId, EdgeId)>, + pub new_faces: Vec, +} + +/// A split point along a stroke segment, in stroke-parameter order. +#[derive(Debug, Clone)] +struct SegmentSplit { + /// Parameter on the stroke segment where the split occurs. + t: f64, + /// The vertex at the split point (already created by splitting the existing edge). + vertex: VertexId, +} + +/// Endpoint proximity threshold: intersections this close to an endpoint +/// are filtered (vertex snapping handles them instead). +const ENDPOINT_T_MARGIN: f64 = 0.01; + +impl Dcel { + /// For a single stroke segment, find all intersections with existing edges. + /// For each intersection, immediately split the existing edge and create a + /// shared vertex. Returns the split points sorted by t along the segment. + /// + /// For each existing edge, we find ALL intersections at once, then split + /// that edge at all of them (high-t to low-t, remapping t values as the + /// edge shortens). This correctly handles a stroke segment crossing the + /// same edge multiple times. + fn intersect_and_split_segment( + &mut self, + segment: &CubicBez, + result: &mut InsertStrokeResult, + ) -> Vec { + let mut splits: Vec = Vec::new(); + + // Snapshot edge count. Tail edges created by split_edge are portions + // of edges we already found all intersections for, so they don't need + // re-checking. + let edge_count = self.edges.len(); + + for edge_idx in 0..edge_count { + if self.edges[edge_idx].deleted { + continue; + } + let edge_id = EdgeId(edge_idx as u32); + let edge_curve = self.edges[edge_idx].curve; + + let intersections = find_curve_intersections(segment, &edge_curve); + + // Filter and collect valid hits for this edge + let mut edge_hits: Vec<(f64, f64, Point)> = intersections + .iter() + .filter_map(|ix| { + let seg_t = ix.t1; + let edge_t = ix.t2.unwrap_or(0.5); + if seg_t < ENDPOINT_T_MARGIN || seg_t > 1.0 - ENDPOINT_T_MARGIN { + return None; + } + if edge_t < ENDPOINT_T_MARGIN || edge_t > 1.0 - ENDPOINT_T_MARGIN { + return None; + } + Some((seg_t, edge_t, ix.point)) + }) + .collect(); + + if edge_hits.is_empty() { + continue; + } + + // Sort by edge_t descending — split from the end first so that + // earlier t values remain valid on the (shortening) original edge. + edge_hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + // Track how much of the original edge the current "head" covers. + // After splitting at t, the head covers [0, t] of the original, + // so the next split at t' < t needs remapping: t' / t. + let mut head_end = 1.0_f64; + + for (seg_t, original_edge_t, point) in edge_hits { + // Remap to the current head's parameter space + let remapped_t = original_edge_t / head_end; + let remapped_t = remapped_t.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN); + + let (vertex, new_edge) = self.split_edge(edge_id, remapped_t); + + // Place vertex at the intersection point (shared between both curves) + self.vertices[vertex.idx()].position = point; + self.snap_edge_endpoints_to_vertex(edge_id, vertex); + self.snap_edge_endpoints_to_vertex(new_edge, vertex); + + result.split_edges.push((edge_id, original_edge_t, vertex, new_edge)); + result.new_vertices.push(vertex); + splits.push(SegmentSplit { t: seg_t, vertex }); + + // The head edge now covers [0, original_edge_t] of the original + head_end = original_edge_t; + } + } + + // Sort by t along the stroke segment + splits.sort_by(|a, b| a.t.partial_cmp(&b.t).unwrap()); + + // Deduplicate near-identical splits + splits.dedup_by(|a, b| (a.t - b.t).abs() < ENDPOINT_T_MARGIN); + + splits + } + + /// Insert a multi-segment stroke into the DCEL. + /// + /// For each segment: + /// 1. Find intersections with existing edges and split them immediately + /// 2. Snap segment start/end to existing vertices or create new ones + /// 3. Build a vertex chain: [seg_start, intersection_vertices..., seg_end] + /// 4. Insert sub-edges between consecutive chain vertices + pub fn insert_stroke( + &mut self, + segments: &[CubicBez], + stroke_style: Option, + stroke_color: Option, + epsilon: f64, + ) -> InsertStrokeResult { + if let Some(ref mut rec) = self.debug_recorder { + rec.record_stroke(segments); + } + + let mut result = InsertStrokeResult { + new_vertices: Vec::new(), + new_edges: Vec::new(), + split_edges: Vec::new(), + new_faces: Vec::new(), + }; + + if segments.is_empty() { + return result; + } + + // Pre-pass: split any self-intersecting segments into two. + // A cubic can self-intersect at most once, producing two sub-segments + // that share a vertex at the crossing. This must happen before the + // main loop so the second half can intersect the first half's edge. + let mut expanded: Vec = Vec::with_capacity(segments.len()); + for seg in segments { + if let Some((t1, t2, point)) = Self::find_cubic_self_intersection(seg) { + // Split into 4 sub-segments: [0,t1], [t1,mid], [mid,t2], [t2,1] + // where mid is the midpoint of the loop. This avoids creating + // a loop edge (same start and end vertex) which would break + // the DCEL topology. + let t_mid = (t1 + t2) / 2.0; + + let mut s0 = subsegment_cubic(*seg, 0.0, t1); + let mut s1 = subsegment_cubic(*seg, t1, t_mid); + let mut s2 = subsegment_cubic(*seg, t_mid, t2); + let mut s3 = subsegment_cubic(*seg, t2, 1.0); + + // Snap junctions to the crossing point + s0.p3 = point; + s1.p0 = point; + s2.p3 = point; + s3.p0 = point; + + expanded.push(s0); + expanded.push(s1); + expanded.push(s2); + expanded.push(s3); + } else { + expanded.push(*seg); + } + } + + // Process each segment: find intersections, split existing edges, + // then insert sub-edges for the stroke. + // + // We track prev_vertex so that adjacent segments share their + // junction vertex (the end of segment N is the start of segment N+1). + let mut prev_vertex: Option = None; + + for (seg_idx, seg) in expanded.iter().enumerate() { + // Phase 1: Intersect this segment against all existing edges + let splits = self.intersect_and_split_segment(seg, &mut result); + + // Phase 2: Resolve segment start vertex + let seg_start = if let Some(pv) = prev_vertex { + pv + } else { + self.snap_vertex(seg.p0, epsilon) + .unwrap_or_else(|| self.alloc_vertex(seg.p0)) + }; + + // Phase 3: Resolve segment end vertex + let seg_end = if seg_idx == expanded.len() - 1 { + // Last segment: snap end point + self.snap_vertex(seg.p3, epsilon) + .unwrap_or_else(|| self.alloc_vertex(seg.p3)) + } else { + // Interior joint: snap to the shared endpoint with next segment + self.snap_vertex(seg.p3, epsilon) + .unwrap_or_else(|| self.alloc_vertex(seg.p3)) + }; + + // Phase 4: Build vertex chain + let mut chain: Vec<(f64, VertexId)> = Vec::with_capacity(splits.len() + 2); + chain.push((0.0, seg_start)); + for s in &splits { + chain.push((s.t, s.vertex)); + } + chain.push((1.0, seg_end)); + + // Remove consecutive duplicates (e.g. if seg_start snapped to a split vertex) + chain.dedup_by(|a, b| a.1 == b.1); + + // Phase 5: Insert sub-edges + for pair in chain.windows(2) { + let (t0, v0) = pair[0]; + let (t1, v1) = pair[1]; + if v0 == v1 { + continue; + } + + let mut sub_curve = subsegment_cubic(*seg, t0, t1); + // Snap curve endpoints to exact vertex positions + sub_curve.p0 = self.vertices[v0.idx()].position; + sub_curve.p3 = self.vertices[v1.idx()].position; + + // Determine face by probing the curve midpoint + let mid = sub_curve.eval(0.5); + let face = self.find_face_at_point(mid).face; + + let (edge_id, new_face) = self.insert_edge(v0, v1, face, sub_curve); + + self.edges[edge_id.idx()].stroke_style = stroke_style.clone(); + self.edges[edge_id.idx()].stroke_color = stroke_color; + + result.new_edges.push(edge_id); + if new_face != face && new_face.0 != 0 { + result.new_faces.push(new_face); + } + } + + // Track vertices + if !result.new_vertices.contains(&seg_start) { + result.new_vertices.push(seg_start); + } + if !result.new_vertices.contains(&seg_end) { + result.new_vertices.push(seg_end); + } + + prev_vertex = Some(seg_end); + } + + #[cfg(debug_assertions)] + self.validate(); + + result + } + + /// Find the self-intersection of a cubic bezier, if any. + /// + /// A cubic can self-intersect at most once. Returns Some((t1, t2, point)) + /// with t1 < t2 if the curve crosses itself, None otherwise. + /// + /// Algebraic approach: B(t) = P0 + 3at + 3bt² + ct³ where + /// a = P1-P0, b = P2-2P1+P0, c = P3-3P2+3P1-P0 + /// + /// B(t1) = B(t2), factor (t1-t2), let s=t1+t2, p=t1*t2: + /// 3a + 3b·s + c·(s²-p) = 0 (two equations, x and y) + /// + /// Cross-product elimination gives s, back-substitution gives p, + /// then t1,t2 = (s ± √(s²-4p)) / 2. + fn find_cubic_self_intersection(curve: &CubicBez) -> Option<(f64, f64, Point)> { + 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; + + // s = -(a × c) / (b × c) where × is 2D cross product + let b_cross_c = bx * cy - by * cx; + if b_cross_c.abs() < 1e-10 { + return None; // degenerate — no self-intersection + } + + let a_cross_c = ax * cy - ay * cx; + let s = -a_cross_c / b_cross_c; + + // Back-substitute to find p. Use whichever component of c is larger + // to avoid division by near-zero. + let p = if cx.abs() > cy.abs() { + // From x: cx*(s²-p) + 3*bx*s + 3*ax = 0 + // p = s² + (3*bx*s + 3*ax) / cx + s * s + (3.0 * bx * s + 3.0 * ax) / cx + } else if cy.abs() > 1e-10 { + s * s + (3.0 * by * s + 3.0 * ay) / cy + } else { + return None; + }; + + // t1, t2 = (s ± √(s²-4p)) / 2 + 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; + + // Both must be strictly inside (0, 1) + if t1 <= ENDPOINT_T_MARGIN || t2 >= 1.0 - ENDPOINT_T_MARGIN || t1 >= t2 { + return None; + } + + let p1 = curve.eval(t1); + let p2 = curve.eval(t2); + let point = Point::new((p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5); + + Some((t1, t2, point)) + } + + /// Recompute intersections for a single edge against all other edges. + /// + /// Used after editing a curve's control points — finds new crossings and + /// splits both curves at each intersection. Returns the list of + /// (new_vertex, new_edge) pairs created by splits. + pub fn recompute_edge_intersections( + &mut self, + edge_id: EdgeId, + ) -> Vec<(VertexId, EdgeId)> { + if self.edges[edge_id.idx()].deleted { + return Vec::new(); + } + + let curve = self.edges[edge_id.idx()].curve; + let mut created = Vec::new(); + + // 1. Check for self-intersection (loop in this single curve) + if let Some((t1, t2, point)) = Self::find_cubic_self_intersection(&curve) { + // Split into 4 sub-edges: [0,t1], [t1,mid], [mid,t2], [t2,1] + // This avoids creating a self-loop edge (same start and end vertex). + let t_mid = (t1 + t2) / 2.0; + + // Create one crossing vertex and one loop midpoint vertex + let cv = self.alloc_vertex(point); + let mid_point = curve.eval(t_mid); + let v_mid = self.alloc_vertex(mid_point); + + // Split high-t to low-t, reusing cv for both crossing points + let (_, tail_edge) = self.split_edge_at_vertex(edge_id, t2, cv); + created.push((cv, tail_edge)); + + let remapped_mid = t_mid / t2; + let (_, mid_edge2) = self.split_edge_at_vertex(edge_id, remapped_mid, v_mid); + created.push((v_mid, mid_edge2)); + + let remapped_t1 = t1 / t_mid; + let (_, mid_edge1) = self.split_edge_at_vertex(edge_id, remapped_t1, cv); + created.push((cv, mid_edge1)); + + // Splits inserted cv twice without maintaining the CCW fan — fix it + self.rebuild_vertex_fan(cv); + } + + // 2. Check against all other edges + let edge_count = self.edges.len(); + for other_idx in 0..edge_count { + if self.edges[other_idx].deleted { + continue; + } + let other_id = EdgeId(other_idx as u32); + if other_id == edge_id { + continue; + } + + // Also skip edges created by splitting edge_id above + // (they are pieces of the same curve) + if created.iter().any(|&(_, e)| e == other_id) { + continue; + } + + let other_curve = self.edges[other_idx].curve; + let intersections = find_curve_intersections(&curve, &other_curve); + + let mut hits: Vec<(f64, f64, Point)> = intersections + .iter() + .filter_map(|ix| { + let seg_t = ix.t1; + let edge_t = ix.t2.unwrap_or(0.5); + if seg_t < ENDPOINT_T_MARGIN || seg_t > 1.0 - ENDPOINT_T_MARGIN { + return None; + } + if edge_t < ENDPOINT_T_MARGIN || edge_t > 1.0 - ENDPOINT_T_MARGIN { + return None; + } + Some((seg_t, edge_t, ix.point)) + }) + .collect(); + + if hits.is_empty() { + continue; + } + + // Sort by edge_t descending — split from end first + hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + let mut head_end = 1.0_f64; + for (_seg_t, original_edge_t, point) in hits { + let remapped_t = (original_edge_t / head_end) + .clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN); + + let (vertex, new_edge) = self.split_edge(other_id, remapped_t); + self.vertices[vertex.idx()].position = point; + self.snap_edge_endpoints_to_vertex(other_id, vertex); + self.snap_edge_endpoints_to_vertex(new_edge, vertex); + + created.push((vertex, new_edge)); + head_end = original_edge_t; + } + } + + created + } + + /// Ensure that any edge endpoint touching `vertex` has its curve snapped + /// to the vertex's exact position. + fn snap_edge_endpoints_to_vertex(&mut self, edge_id: EdgeId, vertex: VertexId) { + let vpos = self.vertices[vertex.idx()].position; + let edge = &self.edges[edge_id.idx()]; + let [fwd, bwd] = edge.half_edges; + + if self.half_edges[fwd.idx()].origin == vertex { + self.edges[edge_id.idx()].curve.p0 = vpos; + } + if self.half_edges[bwd.idx()].origin == vertex { + self.edges[edge_id.idx()].curve.p3 = vpos; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Point; + + #[test] + fn u_and_c_four_intersections() { + let mut dcel = Dcel::new(); + + let u_curve = CubicBez::new( + Point::new(0.0, 100.0), + Point::new(0.0, -40.0), + Point::new(100.0, -40.0), + Point::new(100.0, 100.0), + ); + + let v1 = dcel.alloc_vertex(u_curve.p0); + let v2 = dcel.alloc_vertex(u_curve.p3); + dcel.insert_edge(v1, v2, FaceId(0), u_curve); + + let c_curve = CubicBez::new( + Point::new(120.0, 80.0), + Point::new(-40.0, 80.0), + Point::new(-40.0, 20.0), + Point::new(120.0, 20.0), + ); + + let mut result = InsertStrokeResult { + new_vertices: Vec::new(), + new_edges: Vec::new(), + split_edges: Vec::new(), + new_faces: Vec::new(), + }; + + let splits = dcel.intersect_and_split_segment(&c_curve, &mut result); + + println!("Found {} splits:", splits.len()); + for (i, s) in splits.iter().enumerate() { + let pos = dcel.vertices[s.vertex.idx()].position; + println!(" {i}: t={:.4} V{} ({:.2}, {:.2})", s.t, s.vertex.0, pos.x, pos.y); + } + + assert_eq!(splits.len(), 4, "U and C should cross 4 times"); + assert_eq!(result.split_edges.len(), 4); + + let split_verts: Vec = splits.iter().map(|s| s.vertex).collect(); + + // All split vertices distinct + for i in 0..split_verts.len() { + for j in (i + 1)..split_verts.len() { + assert_ne!(split_verts[i], split_verts[j]); + } + } + + // t-values ascending along C + for w in splits.windows(2) { + assert!(w[0].t < w[1].t); + } + + // --- Verify U is now 5 edges chained through the 4 split vertices --- + // Walk from v1 along forward half-edges to v2. + // The original edge (edge 0) was shortened; tails were appended. + // Walk: v1 → split_v[highest_edge_t] → ... → split_v[lowest_edge_t] → v2 + // (splits were high-t-first, so the edge chain from v1 goes through + // the lowest-edge_t vertex first) + let mut u_chain: Vec = vec![v1]; + let mut cur_he = dcel.vertices[v1.idx()].outgoing; + for _ in 0..10 { + let dest = dcel.half_edge_dest(cur_he); + u_chain.push(dest); + if dest == v2 { + break; + } + // Follow forward: next half-edge in the cycle from dest + // For a chain in F0, the forward half-edge's next is the backward + // of the same spur, so we need to use the twin's next instead + // to walk along the chain. + let twin = dcel.half_edges[cur_he.idx()].twin; + // At dest, find the outgoing half-edge that continues the chain + // (not the one going back the way we came) + let outgoing = dcel.vertex_outgoing(dest); + let back_he = twin; // the half-edge arriving at dest from our direction + // The next edge in the chain is the outgoing that isn't the return + cur_he = *outgoing.iter() + .find(|&&he| he != dcel.half_edges[back_he.idx()].next + || outgoing.len() == 1) + .unwrap_or(&outgoing[0]); + // Actually for a simple chain (degree-2 vertices), there are exactly + // 2 outgoing half-edges; pick the one that isn't the twin of how we arrived + if outgoing.len() == 2 { + let arriving_twin = dcel.half_edges[cur_he.idx()].twin; + // We want the outgoing that is NOT the reverse of our arrival + cur_he = if outgoing[0] == dcel.half_edges[twin.idx()].next { + // twin.next is the next outgoing in the fan — that's continuing back + // For degree-2: the two outgoing are twin.next of each other + // We want the one that is NOT going back toward v1 + outgoing[1] + } else { + outgoing[0] + }; + } + } + + // Simpler approach: just verify that all 4 split vertices appear as + // endpoints of non-deleted edges, and that v1 and v2 are still endpoints. + let mut u_edge_vertices: Vec = Vec::new(); + for (i, edge) in dcel.edges.iter().enumerate() { + if edge.deleted { continue; } + let [fwd, bwd] = edge.half_edges; + let a = dcel.half_edges[fwd.idx()].origin; + let b = dcel.half_edges[bwd.idx()].origin; + u_edge_vertices.push(a); + u_edge_vertices.push(b); + } + + // v1 and v2 (U endpoints) should still be edge endpoints + assert!(u_edge_vertices.contains(&v1), "v1 should be an edge endpoint"); + assert!(u_edge_vertices.contains(&v2), "v2 should be an edge endpoint"); + + // All 4 split vertices should be edge endpoints (they split the U) + for &sv in &split_verts { + assert!(u_edge_vertices.contains(&sv), + "split vertex V{} should be an edge endpoint", sv.0); + } + + // Should have exactly 5 non-deleted edges (original U split into 5) + let live_edges: Vec = dcel.edges.iter().enumerate() + .filter(|(_, e)| !e.deleted) + .map(|(i, _)| EdgeId(i as u32)) + .collect(); + assert_eq!(live_edges.len(), 5, "U should be split into 5 edges"); + + // Each split vertex should have degree 2 (connects two edge pieces) + for &sv in &split_verts { + let out = dcel.vertex_outgoing(sv); + assert_eq!(out.len(), 2, + "split vertex V{} should have degree 2, got {}", sv.0, out.len()); + } + + // --- Verify C sub-curves would share the same vertices --- + // The C would be split into 5 sub-curves at t-values [0, t0, t1, t2, t3, 1]. + // Each sub-curve's endpoints should snap to the split vertices. + let mut c_t_values: Vec = vec![0.0]; + c_t_values.extend(splits.iter().map(|s| s.t)); + c_t_values.push(1.0); + + for i in 0..5 { + let t0 = c_t_values[i]; + let t1 = c_t_values[i + 1]; + let sub = subsegment_cubic(c_curve, t0, t1); + + // Start point of sub-curve should match a known vertex + if i > 0 { + let expected_v = split_verts[i - 1]; + let expected_pos = dcel.vertices[expected_v.idx()].position; + let dist = ((sub.p0.x - expected_pos.x).powi(2) + + (sub.p0.y - expected_pos.y).powi(2)).sqrt(); + assert!(dist < 2.0, + "C sub-curve {i} start ({:.2},{:.2}) should be near V{} ({:.2},{:.2}), dist={:.3}", + sub.p0.x, sub.p0.y, expected_v.0, expected_pos.x, expected_pos.y, dist); + } + + // End point should match + if i < 4 { + let expected_v = split_verts[i]; + let expected_pos = dcel.vertices[expected_v.idx()].position; + let dist = ((sub.p3.x - expected_pos.x).powi(2) + + (sub.p3.y - expected_pos.y).powi(2)).sqrt(); + assert!(dist < 2.0, + "C sub-curve {i} end ({:.2},{:.2}) should be near V{} ({:.2},{:.2}), dist={:.3}", + sub.p3.x, sub.p3.y, expected_v.0, expected_pos.x, expected_pos.y, dist); + } + } + + dcel.validate(); + } + + #[test] + fn insert_stroke_u_then_c() { + let mut dcel = Dcel::new(); + + // Insert U as a stroke + let u_curve = CubicBez::new( + Point::new(0.0, 100.0), + Point::new(0.0, -40.0), + Point::new(100.0, -40.0), + Point::new(100.0, 100.0), + ); + let u_result = dcel.insert_stroke(&[u_curve], None, None, 0.5); + assert_eq!(u_result.new_edges.len(), 1); + + // Insert C as a stroke — should split both curves at 4 intersections + let c_curve = CubicBez::new( + Point::new(120.0, 80.0), + Point::new(-40.0, 80.0), + Point::new(-40.0, 20.0), + Point::new(120.0, 20.0), + ); + let c_result = dcel.insert_stroke(&[c_curve], None, None, 0.5); + + println!("C stroke: {} new edges, {} split edges, {} new vertices", + c_result.new_edges.len(), c_result.split_edges.len(), c_result.new_vertices.len()); + + // U was split at 4 points → 4 split_edges + assert_eq!(c_result.split_edges.len(), 4); + + // C was inserted as 5 sub-edges (split at the 4 intersection points) + assert_eq!(c_result.new_edges.len(), 5); + + // Total live edges: 5 (U pieces) + 5 (C pieces) = 10 + let live_edges = dcel.edges.iter().filter(|e| !e.deleted).count(); + assert_eq!(live_edges, 10); + + // The 4 intersection vertices should each have degree 4 + // (2 from U chain + 2 from C chain) + let split_verts: Vec = c_result.split_edges.iter() + .map(|&(_, _, v, _)| v) + .collect(); + for &sv in &split_verts { + let degree = dcel.vertex_outgoing(sv).len(); + assert_eq!(degree, 4, + "intersection vertex V{} should have degree 4, got {}", + sv.0, degree); + } + + dcel.validate(); + } + + #[test] + fn insert_stroke_simple_cross() { + let mut dcel = Dcel::new(); + + // Horizontal line + let h = CubicBez::new( + Point::new(0.0, 50.0), + Point::new(33.0, 50.0), + Point::new(66.0, 50.0), + Point::new(100.0, 50.0), + ); + dcel.insert_stroke(&[h], None, None, 0.5); + + // Vertical line crossing it + let v = CubicBez::new( + Point::new(50.0, 0.0), + Point::new(50.0, 33.0), + Point::new(50.0, 66.0), + Point::new(50.0, 100.0), + ); + let result = dcel.insert_stroke(&[v], None, None, 0.5); + + // One intersection + assert_eq!(result.split_edges.len(), 1); + // Vertical inserted as 2 sub-edges + assert_eq!(result.new_edges.len(), 2); + // Total: 2 (H pieces) + 2 (V pieces) = 4 + let live = dcel.edges.iter().filter(|e| !e.deleted).count(); + assert_eq!(live, 4); + + // Intersection vertex has degree 4 + let ix_v = result.split_edges[0].2; + assert_eq!(dcel.vertex_outgoing(ix_v).len(), 4); + + dcel.validate(); + } + + /// Multi-segment stroke that loops back and crosses itself: + /// + /// seg0: right → + /// seg1: down ↓ + /// seg2: left ← (crosses seg0) + /// + /// Since segments are inserted sequentially, seg2 should find and split + /// the already-inserted seg0 edge at the crossing. + #[test] + fn insert_stroke_self_crossing_multi_segment() { + let mut dcel = Dcel::new(); + + let seg0 = CubicBez::new( + Point::new(0.0, 50.0), + Point::new(33.0, 50.0), + Point::new(66.0, 50.0), + Point::new(100.0, 50.0), + ); + let seg1 = CubicBez::new( + Point::new(100.0, 50.0), + Point::new(100.0, 66.0), + Point::new(100.0, 83.0), + Point::new(100.0, 100.0), + ); + let seg2 = CubicBez::new( + Point::new(100.0, 100.0), + Point::new(66.0, 100.0), + Point::new(33.0, 0.0), + Point::new(0.0, 0.0), + ); + + let result = dcel.insert_stroke(&[seg0, seg1, seg2], None, None, 0.5); + + println!("Self-crossing: {} edges, {} splits, {} vertices", + result.new_edges.len(), result.split_edges.len(), result.new_vertices.len()); + + // seg2 should cross seg0 once + assert_eq!(result.split_edges.len(), 1, "seg2 should cross seg0 once"); + + // Crossing vertex should have degree 4 + let ix_v = result.split_edges[0].2; + let degree = dcel.vertex_outgoing(ix_v).len(); + assert_eq!(degree, 4, + "self-crossing vertex should have degree 4, got {}", degree); + + dcel.validate(); + } + + #[test] + fn find_self_intersection_loop() { + // Asymmetric control points that form a true loop (not a cusp). + // The wider spread gives disc > 0, so t1 ≠ t2. + let curve = CubicBez::new( + Point::new(0.0, 0.0), + Point::new(200.0, 100.0), + Point::new(-100.0, 100.0), + Point::new(100.0, 0.0), + ); + + let result = Dcel::find_cubic_self_intersection(&curve); + assert!(result.is_some(), "curve should self-intersect"); + + let (t1, t2, point) = result.unwrap(); + println!("Self-ix: t1={t1:.4} t2={t2:.4} at ({:.2}, {:.2})", point.x, point.y); + assert!(t1 > 0.0 && t1 < 1.0); + assert!(t2 > t1 && t2 < 1.0); + // Crossing point should be near the middle of the curve + assert!((point.x - 50.0).abs() < 20.0); + } + + #[test] + fn find_self_intersection_none_for_simple_curve() { + let curve = CubicBez::new( + Point::new(0.0, 0.0), + Point::new(33.0, 0.0), + Point::new(66.0, 0.0), + Point::new(100.0, 0.0), + ); + assert!(Dcel::find_cubic_self_intersection(&curve).is_none()); + } + + /// Simulate the editor flow: insert a straight edge, then change its + /// curve to a self-intersecting loop, then call recompute_edge_intersections. + /// The crossing vertex should have degree 4 (4 edges meeting there). + #[test] + fn recompute_self_intersecting_edge() { + let mut dcel = Dcel::new(); + + // Insert a straight edge + let p0 = Point::new(0.0, 0.0); + let p1 = Point::new(100.0, 0.0); + let v0 = dcel.alloc_vertex(p0); + let v1 = dcel.alloc_vertex(p1); + let straight = CubicBez::new(p0, Point::new(33.0, 0.0), Point::new(66.0, 0.0), p1); + let (edge_id, _) = dcel.insert_edge(v0, v1, FaceId(0), straight); + + assert_eq!(dcel.edges.iter().filter(|e| !e.deleted).count(), 1); + + // Mutate the curve to be self-intersecting (like the user dragging control points) + dcel.edges[edge_id.idx()].curve = CubicBez::new( + p0, + Point::new(200.0, 100.0), + Point::new(-100.0, 100.0), + p1, + ); + + // Recompute — should detect self-intersection and split + let created = dcel.recompute_edge_intersections(edge_id); + println!("recompute created {} splits", created.len()); + + // Should have 4 live edges: [0,t1], [t1,mid], [mid,t2], [t2,1] + let live_edges = dcel.edges.iter().filter(|e| !e.deleted).count(); + println!("live edges: {live_edges}"); + assert_eq!(live_edges, 4, "self-intersecting curve should become 4 edges"); + + // Find the crossing vertex: it's the one with degree 4 + let mut crossing_vertex = None; + for (i, v) in dcel.vertices.iter().enumerate() { + if v.deleted || v.outgoing.is_none() { continue; } + let vid = super::super::VertexId(i as u32); + let degree = dcel.vertex_outgoing(vid).len(); + println!("V{i}: degree={degree} pos=({:.1},{:.1})", v.position.x, v.position.y); + if degree == 4 { + crossing_vertex = Some(vid); + } + } + + let cv = crossing_vertex.expect("should have a degree-4 crossing vertex"); + + // All 4 outgoing half-edges should belong to different edges + let outgoing = dcel.vertex_outgoing(cv); + assert_eq!(outgoing.len(), 4, "crossing vertex should have degree 4"); + + let mut edge_ids: Vec = outgoing + .iter() + .map(|&he| dcel.half_edges[he.idx()].edge) + .collect(); + edge_ids.sort_by_key(|e| e.0); + edge_ids.dedup(); + assert_eq!(edge_ids.len(), 4, "all 4 outgoing should be on different edges"); + + // Verify all 4 edges have the crossing vertex as an endpoint + for &eid in &edge_ids { + let [fwd, bwd] = dcel.edges[eid.idx()].half_edges; + let va = dcel.half_edges[fwd.idx()].origin; + let vb = dcel.half_edges[bwd.idx()].origin; + assert!( + va == cv || vb == cv, + "edge E{} endpoints V{},V{} should include crossing vertex V{}", + eid.0, va.0, vb.0, cv.0 + ); + } + + dcel.validate(); + } + + #[test] + fn insert_stroke_self_intersecting_segment() { + let mut dcel = Dcel::new(); + + // Single segment that loops on itself (same curve as find_self_intersection_loop) + let loop_curve = CubicBez::new( + Point::new(0.0, 0.0), + Point::new(200.0, 100.0), + Point::new(-100.0, 100.0), + Point::new(100.0, 0.0), + ); + + let result = dcel.insert_stroke(&[loop_curve], None, None, 0.5); + + // Expanded to 4 sub-segments: [0,t1], [t1,mid], [mid,t2], [t2,1] + // The loop is split in half to avoid a same-vertex edge. + assert_eq!(result.new_edges.len(), 4); + + dcel.validate(); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs new file mode 100644 index 0000000..940b927 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs @@ -0,0 +1,715 @@ +//! Pure topology operations on the DCEL. +//! +//! Core invariants maintained by all operations: +//! - Half-edges leaving a vertex are in sorted CCW order by angle. +//! The fan is traversed via: `twin(he).next` gives the next CCW outgoing. +//! - `he.next` walks CCW around the face to the left of `he`. +//! - `he.prev` is the inverse of `next`. +//! - `he.twin.origin` is the destination of `he`. +//! - Faces are only created when splitting an existing non-F0 face. + +use super::{ + subdivide_cubic, Dcel, EdgeId, FaceId, HalfEdgeId, VertexId, DEFAULT_SNAP_EPSILON, +}; +use kurbo::CubicBez; + +impl Dcel { + /// Angle of the curve's forward direction at its start (p0 → p1, fallback p0 → p3). + pub fn curve_start_angle(curve: &CubicBez) -> f64 { + let dx = curve.p1.x - curve.p0.x; + let dy = curve.p1.y - curve.p0.y; + if dx * dx + dy * dy > 1e-18 { + dy.atan2(dx) + } else { + (curve.p3.y - curve.p0.y).atan2(curve.p3.x - curve.p0.x) + } + } + + /// Angle of the curve's backward direction at its end (p3 → p2, fallback p3 → p0). + pub fn curve_end_angle(curve: &CubicBez) -> f64 { + let dx = curve.p2.x - curve.p3.x; + let dy = curve.p2.y - curve.p3.y; + if dx * dx + dy * dy > 1e-18 { + dy.atan2(dx) + } else { + (curve.p0.y - curve.p3.y).atan2(curve.p0.x - curve.p3.x) + } + } + + /// Outgoing angle of a half-edge at its origin vertex. + pub fn outgoing_angle(&self, he: HalfEdgeId) -> f64 { + let edge = &self.edges[self.half_edges[he.idx()].edge.idx()]; + if he == edge.half_edges[0] { + Self::curve_start_angle(&edge.curve) + } else { + Self::curve_end_angle(&edge.curve) + } + } + + /// Find the existing outgoing half-edge from `vertex` that is the immediate + /// CCW successor of `angle` in the vertex fan. + /// + /// Returns the half-edge whose angular position is the smallest CCW rotation + /// from `angle`. This is where a new edge at `angle` should be spliced before. + fn find_ccw_successor(&self, vertex: VertexId, angle: f64) -> HalfEdgeId { + let start = self.vertices[vertex.idx()].outgoing; + debug_assert!(!start.is_none(), "find_ccw_successor on isolated vertex"); + + let mut best = start; + let mut best_delta = f64::MAX; + let mut cur = start; + loop { + let a = self.outgoing_angle(cur); + let mut delta = a - angle; + if delta <= 0.0 { + delta += std::f64::consts::TAU; + } + if delta < best_delta { + best_delta = delta; + best = cur; + } + let twin = self.half_edges[cur.idx()].twin; + cur = self.half_edges[twin.idx()].next; + if cur == start { + break; + } + } + best + } + + /// Insert an edge between two existing vertices. + /// + /// `face` is the face that both vertices lie on (for the (true, true) case, + /// the face is determined by the angular sector and `face` is ignored). + /// + /// Returns `(edge_id, face_id)` where `face_id` is: + /// - A new face if the edge split an existing non-F0 face + /// - The face the edge was inserted into otherwise + /// + /// # Face creation rules + /// - Faces are only created when both vertices are on the same boundary cycle + /// of a face that is NOT face 0 (the unbounded face). This is the "split" case. + /// - Creating a closed cycle in face 0 does NOT auto-create a face. + /// - The cycle containing the old face's `outer_half_edge` keeps the old face. + /// The other cycle gets a new face with inherited fill data. + pub fn insert_edge( + &mut self, + v1: VertexId, + v2: VertexId, + face: FaceId, + curve: CubicBez, + ) -> (EdgeId, FaceId) { + debug_assert!(v1 != v2, "cannot insert self-loop"); + + let v1_isolated = self.vertices[v1.idx()].outgoing.is_none(); + let v2_isolated = self.vertices[v2.idx()].outgoing.is_none(); + + // Allocate edge + half-edge pair + let (he_fwd, he_bwd) = self.alloc_half_edge_pair(); + let edge_id = self.alloc_edge(curve); + + self.edges[edge_id.idx()].half_edges = [he_fwd, he_bwd]; + self.half_edges[he_fwd.idx()].edge = edge_id; + self.half_edges[he_bwd.idx()].edge = edge_id; + self.half_edges[he_fwd.idx()].origin = v1; + self.half_edges[he_bwd.idx()].origin = v2; + + match (v1_isolated, v2_isolated) { + (true, true) => { + self.insert_edge_both_isolated(he_fwd, he_bwd, v1, v2, edge_id, face) + } + (false, false) => { + self.insert_edge_both_connected(he_fwd, he_bwd, v1, v2, edge_id, &curve) + } + _ => { + self.insert_edge_one_isolated(he_fwd, he_bwd, v1, v2, edge_id, &curve, v1_isolated) + } + } + } + + /// Both vertices isolated: first edge, no face split possible. + fn insert_edge_both_isolated( + &mut self, + he_fwd: HalfEdgeId, + he_bwd: HalfEdgeId, + v1: VertexId, + v2: VertexId, + edge_id: EdgeId, + face: FaceId, + ) -> (EdgeId, FaceId) { + // Two half-edges form a trivial 2-cycle + self.half_edges[he_fwd.idx()].next = he_bwd; + self.half_edges[he_fwd.idx()].prev = he_bwd; + self.half_edges[he_bwd.idx()].next = he_fwd; + self.half_edges[he_bwd.idx()].prev = he_fwd; + + self.half_edges[he_fwd.idx()].face = face; + self.half_edges[he_bwd.idx()].face = face; + + // Register with face + if face.0 == 0 { + self.faces[0].inner_half_edges.push(he_fwd); + } else if self.faces[face.idx()].outer_half_edge.is_none() { + self.faces[face.idx()].outer_half_edge = he_fwd; + } + + self.vertices[v1.idx()].outgoing = he_fwd; + self.vertices[v2.idx()].outgoing = he_bwd; + + (edge_id, face) + } + + /// One vertex isolated, one connected: spur/antenna edge, no face split. + fn insert_edge_one_isolated( + &mut self, + he_fwd: HalfEdgeId, + he_bwd: HalfEdgeId, + v1: VertexId, + v2: VertexId, + edge_id: EdgeId, + curve: &CubicBez, + v1_is_isolated: bool, + ) -> (EdgeId, FaceId) { + let (connected, isolated) = if v1_is_isolated { (v2, v1) } else { (v1, v2) }; + + // Determine which half-edge goes OUT from connected vertex + let (he_out, he_back) = if self.half_edges[he_fwd.idx()].origin == connected { + (he_fwd, he_bwd) + } else { + (he_bwd, he_fwd) + }; + + // Find where to splice in the fan at the connected vertex + let out_angle = if self.half_edges[he_fwd.idx()].origin == connected { + Self::curve_start_angle(curve) + } else { + Self::curve_end_angle(curve) + }; + let ccw_succ = self.find_ccw_successor(connected, out_angle); + let he_into = self.half_edges[ccw_succ.idx()].prev; + let actual_face = self.half_edges[he_into.idx()].face; + + // Splice: ... → he_into → [he_out → he_back] → ccw_succ → ... + self.half_edges[he_into.idx()].next = he_out; + self.half_edges[he_out.idx()].prev = he_into; + self.half_edges[he_out.idx()].next = he_back; + self.half_edges[he_back.idx()].prev = he_out; + self.half_edges[he_back.idx()].next = ccw_succ; + self.half_edges[ccw_succ.idx()].prev = he_back; + + self.half_edges[he_out.idx()].face = actual_face; + self.half_edges[he_back.idx()].face = actual_face; + + self.vertices[isolated.idx()].outgoing = he_back; + + (edge_id, actual_face) + } + + /// Both vertices connected: may split a face. + fn insert_edge_both_connected( + &mut self, + he_fwd: HalfEdgeId, + he_bwd: HalfEdgeId, + v1: VertexId, + v2: VertexId, + edge_id: EdgeId, + curve: &CubicBez, + ) -> (EdgeId, FaceId) { + let fwd_angle = Self::curve_start_angle(curve); + let bwd_angle = Self::curve_end_angle(curve); + + let ccw_v1 = self.find_ccw_successor(v1, fwd_angle); + let ccw_v2 = self.find_ccw_successor(v2, bwd_angle); + + let into_v1 = self.half_edges[ccw_v1.idx()].prev; + let into_v2 = self.half_edges[ccw_v2.idx()].prev; + + let actual_face = self.half_edges[into_v1.idx()].face; + + // Splice: + // into_v1 → he_fwd → ccw_v2 → ... + // into_v2 → he_bwd → ccw_v1 → ... + self.half_edges[he_fwd.idx()].prev = into_v1; + self.half_edges[he_fwd.idx()].next = ccw_v2; + self.half_edges[into_v1.idx()].next = he_fwd; + self.half_edges[ccw_v2.idx()].prev = he_fwd; + + self.half_edges[he_bwd.idx()].prev = into_v2; + self.half_edges[he_bwd.idx()].next = ccw_v1; + self.half_edges[into_v2.idx()].next = he_bwd; + self.half_edges[ccw_v1.idx()].prev = he_bwd; + + // Detect split vs bridge: walk from he_fwd. If we return to he_fwd + // without seeing he_bwd, they are on separate cycles → split. + let is_split = !self.cycle_contains(he_fwd, he_bwd); + + if !is_split { + // Bridge: merged two cycles into one. All on actual_face. + self.assign_cycle_face(he_fwd, actual_face); + if actual_face.0 != 0 { + self.faces[actual_face.idx()].outer_half_edge = he_fwd; + } + return (edge_id, actual_face); + } + + // Split case: two separate cycles. + // Only create a new face if the face being split is not F0. + if actual_face.0 == 0 { + // In the unbounded face, just assign both cycles to F0. + self.half_edges[he_fwd.idx()].face = FaceId(0); + self.assign_cycle_face(he_fwd, FaceId(0)); + self.assign_cycle_face(he_bwd, FaceId(0)); + return (edge_id, FaceId(0)); + } + + // Determine which cycle keeps the old face: the one containing + // the old face's outer_half_edge. + let old_ohe = self.faces[actual_face.idx()].outer_half_edge; + let fwd_has_old = !old_ohe.is_none() && self.cycle_contains(he_fwd, old_ohe); + + let (he_old_cycle, he_new_cycle) = if fwd_has_old { + (he_fwd, he_bwd) + } else { + (he_bwd, he_fwd) + }; + + // Old cycle keeps actual_face + self.assign_cycle_face(he_old_cycle, actual_face); + self.faces[actual_face.idx()].outer_half_edge = he_old_cycle; + + // New cycle gets a new face with inherited fill data + let new_face = self.alloc_face(); + self.faces[new_face.idx()].fill_color = self.faces[actual_face.idx()].fill_color; + self.faces[new_face.idx()].image_fill = self.faces[actual_face.idx()].image_fill; + self.faces[new_face.idx()].fill_rule = self.faces[actual_face.idx()].fill_rule; + self.faces[new_face.idx()].outer_half_edge = he_new_cycle; + self.assign_cycle_face(he_new_cycle, new_face); + + (edge_id, new_face) + } + + /// Check if walking the cycle from `start` encounters `target`. + fn cycle_contains(&self, start: HalfEdgeId, target: HalfEdgeId) -> bool { + let mut cur = self.half_edges[start.idx()].next; + let mut steps = 0; + while cur != start { + if cur == target { + return true; + } + cur = self.half_edges[cur.idx()].next; + steps += 1; + debug_assert!(steps < 100_000, "infinite cycle in cycle_contains"); + } + false + } + + /// Set the face of every half-edge in the cycle starting at `start`. + fn assign_cycle_face(&mut self, start: HalfEdgeId, face: FaceId) { + self.half_edges[start.idx()].face = face; + let mut cur = self.half_edges[start.idx()].next; + let mut steps = 0; + while cur != start { + self.half_edges[cur.idx()].face = face; + cur = self.half_edges[cur.idx()].next; + steps += 1; + debug_assert!(steps < 100_000, "infinite cycle in assign_cycle_face"); + } + } + + /// Split an edge at parameter `t`, inserting a new vertex. + /// + /// The original edge is shortened to [0, t]. A new edge covers [t, 1]. + /// Stroke style is copied to the new edge. + /// Returns `(new_vertex, new_edge)`. + pub fn split_edge(&mut self, edge_id: EdgeId, t: f64) -> (VertexId, EdgeId) { + let original_curve = self.edges[edge_id.idx()].curve; + let (curve_a, _) = subdivide_cubic(original_curve, t); + let split_point = curve_a.p3; + let vertex = self + .snap_vertex(split_point, DEFAULT_SNAP_EPSILON) + .unwrap_or_else(|| self.alloc_vertex(split_point)); + self.split_edge_at_vertex(edge_id, t, vertex) + } + + /// Split an edge at parameter `t`, using a specific pre-existing vertex. + /// + /// The original edge is shortened to [0, t]. A new edge covers [t, 1]. + /// Curve endpoints are snapped to the vertex position. + /// Returns `(vertex, new_edge)`. + pub fn split_edge_at_vertex( + &mut self, + edge_id: EdgeId, + t: f64, + vertex: VertexId, + ) -> (VertexId, EdgeId) { + debug_assert!((0.0..=1.0).contains(&t), "t out of range"); + + let original_curve = self.edges[edge_id.idx()].curve; + let (mut curve_a, mut curve_b) = subdivide_cubic(original_curve, t); + + let vpos = self.vertices[vertex.idx()].position; + curve_a.p3 = vpos; + curve_b.p0 = vpos; + + let [he_fwd, he_bwd] = self.edges[edge_id.idx()].half_edges; + + // Allocate new edge + half-edge pair for second segment + let (new_he_fwd, new_he_bwd) = self.alloc_half_edge_pair(); + let new_edge_id = self.alloc_edge(curve_b); + + self.edges[new_edge_id.idx()].half_edges = [new_he_fwd, new_he_bwd]; + self.half_edges[new_he_fwd.idx()].edge = new_edge_id; + self.half_edges[new_he_bwd.idx()].edge = new_edge_id; + + // Copy stroke style + self.edges[new_edge_id.idx()].stroke_style = + self.edges[edge_id.idx()].stroke_style.clone(); + self.edges[new_edge_id.idx()].stroke_color = self.edges[edge_id.idx()].stroke_color; + + // Shorten original edge + self.edges[edge_id.idx()].curve = curve_a; + + // Set origins: new_he_fwd goes from vertex onward, + // new_he_bwd goes from old destination toward vertex + self.half_edges[new_he_fwd.idx()].origin = vertex; + let old_dest = self.half_edges[he_bwd.idx()].origin; + self.half_edges[new_he_bwd.idx()].origin = old_dest; + + // Splice new_he_fwd into forward cycle: + // Before: ... → he_fwd → fwd_next → ... + // After: ... → he_fwd → new_he_fwd → fwd_next → ... + let fwd_next = self.half_edges[he_fwd.idx()].next; + self.half_edges[he_fwd.idx()].next = new_he_fwd; + self.half_edges[new_he_fwd.idx()].prev = he_fwd; + self.half_edges[new_he_fwd.idx()].next = fwd_next; + self.half_edges[fwd_next.idx()].prev = new_he_fwd; + self.half_edges[new_he_fwd.idx()].face = self.half_edges[he_fwd.idx()].face; + + // Splice new_he_bwd into backward cycle: + // Before: ... → bwd_prev → he_bwd → ... + // After: ... → bwd_prev → new_he_bwd → he_bwd → ... + let bwd_prev = self.half_edges[he_bwd.idx()].prev; + self.half_edges[bwd_prev.idx()].next = new_he_bwd; + self.half_edges[new_he_bwd.idx()].prev = bwd_prev; + self.half_edges[new_he_bwd.idx()].next = he_bwd; + self.half_edges[he_bwd.idx()].prev = new_he_bwd; + self.half_edges[new_he_bwd.idx()].face = self.half_edges[he_bwd.idx()].face; + + // he_bwd now originates from vertex (it covers [vertex → v1]) + self.half_edges[he_bwd.idx()].origin = vertex; + + // Fix old destination's outgoing if it pointed at he_bwd + if self.vertices[old_dest.idx()].outgoing == he_bwd { + self.vertices[old_dest.idx()].outgoing = new_he_bwd; + } + + // Set vertex's outgoing (may already have one if vertex is shared) + if self.vertices[vertex.idx()].outgoing.is_none() { + self.vertices[vertex.idx()].outgoing = new_he_fwd; + } + + (vertex, new_edge_id) + } + + /// Remove an edge, merging its two adjacent faces. + /// Returns the surviving face (lower ID, always keeps face 0). + pub fn remove_edge(&mut self, edge_id: EdgeId) -> FaceId { + let [he_fwd, he_bwd] = self.edges[edge_id.idx()].half_edges; + let face_a = self.half_edges[he_fwd.idx()].face; + let face_b = self.half_edges[he_bwd.idx()].face; + + let (surviving, dying) = if face_a.0 <= face_b.0 { + (face_a, face_b) + } else { + (face_b, face_a) + }; + + let fwd_prev = self.half_edges[he_fwd.idx()].prev; + let fwd_next = self.half_edges[he_fwd.idx()].next; + let bwd_prev = self.half_edges[he_bwd.idx()].prev; + let bwd_next = self.half_edges[he_bwd.idx()].next; + + let v1 = self.half_edges[he_fwd.idx()].origin; + let v2 = self.half_edges[he_bwd.idx()].origin; + + // Splice out half-edges. Four cases based on adjacency. + if fwd_next == he_bwd && bwd_next == he_fwd { + // Degenerate 2-cycle: both vertices become isolated + self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE; + self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE; + } else if fwd_next == he_bwd { + // Spur: he_fwd → he_bwd consecutive. Remove both. + self.half_edges[fwd_prev.idx()].next = bwd_next; + self.half_edges[bwd_next.idx()].prev = fwd_prev; + self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE; + if self.vertices[v1.idx()].outgoing == he_fwd { + self.vertices[v1.idx()].outgoing = bwd_next; + } + } else if bwd_next == he_fwd { + // Spur: he_bwd → he_fwd consecutive. Remove both. + self.half_edges[bwd_prev.idx()].next = fwd_next; + self.half_edges[fwd_next.idx()].prev = bwd_prev; + self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE; + if self.vertices[v2.idx()].outgoing == he_bwd { + self.vertices[v2.idx()].outgoing = fwd_next; + } + } else { + // Normal: splice out both half-edges + self.half_edges[fwd_prev.idx()].next = bwd_next; + self.half_edges[bwd_next.idx()].prev = fwd_prev; + self.half_edges[bwd_prev.idx()].next = fwd_next; + self.half_edges[fwd_next.idx()].prev = bwd_prev; + + if self.vertices[v1.idx()].outgoing == he_fwd { + self.vertices[v1.idx()].outgoing = bwd_next; + } + if self.vertices[v2.idx()].outgoing == he_bwd { + self.vertices[v2.idx()].outgoing = fwd_next; + } + } + + // Reassign dying face's half-edges to surviving face + if surviving != dying && !dying.is_none() { + let walk_start = self.find_surviving_he_for_face(dying, he_fwd, he_bwd, fwd_next, bwd_next); + if !walk_start.is_none() { + self.assign_cycle_face(walk_start, surviving); + } + // Merge holes + let inner = std::mem::take(&mut self.faces[dying.idx()].inner_half_edges); + self.faces[surviving.idx()].inner_half_edges.extend(inner); + } + + // Fix surviving face's outer_half_edge + if self.faces[surviving.idx()].outer_half_edge == he_fwd + || self.faces[surviving.idx()].outer_half_edge == he_bwd + { + let replacement = [fwd_next, bwd_next] + .into_iter() + .find(|&he| he != he_fwd && he != he_bwd && !self.half_edges[he.idx()].deleted) + .unwrap_or(HalfEdgeId::NONE); + self.faces[surviving.idx()].outer_half_edge = replacement; + } + + // Clean up inner_half_edges references + self.faces[surviving.idx()] + .inner_half_edges + .retain(|&he| he != he_fwd && he != he_bwd); + + // Free + self.free_half_edge(he_fwd); + self.free_half_edge(he_bwd); + self.free_edge(edge_id); + if surviving != dying && !dying.is_none() && dying.0 != 0 { + self.free_face(dying); + } + + surviving + } + + /// Find a valid starting half-edge for walking a dying face's cycle, + /// avoiding the two half-edges being removed. + fn find_surviving_he_for_face( + &self, + dying: FaceId, + he_fwd: HalfEdgeId, + he_bwd: HalfEdgeId, + fwd_next: HalfEdgeId, + bwd_next: HalfEdgeId, + ) -> HalfEdgeId { + let ohe = self.faces[dying.idx()].outer_half_edge; + if !ohe.is_none() && ohe != he_fwd && ohe != he_bwd { + return ohe; + } + for &candidate in &[fwd_next, bwd_next] { + if !candidate.is_none() && candidate != he_fwd && candidate != he_bwd { + return candidate; + } + } + HalfEdgeId::NONE + } + + /// Re-sort all outgoing half-edges at a vertex by angle and fix the + /// fan linkage (`twin.next` / `prev`). Call this after operations that + /// add outgoing half-edges to an existing vertex without maintaining + /// the CCW fan invariant (e.g. multiple `split_edge_at_vertex` calls + /// reusing the same vertex). + pub fn rebuild_vertex_fan(&mut self, vertex: VertexId) { + let start = self.vertices[vertex.idx()].outgoing; + if start.is_none() { + return; + } + + // Collect all outgoing half-edges by walking all connected sub-fans. + // The fan may be broken into disconnected loops, so we gather them + // by scanning all half-edges with origin == vertex. + let mut fan: Vec<(f64, HalfEdgeId)> = Vec::new(); + for (i, he) in self.half_edges.iter().enumerate() { + if he.deleted { + continue; + } + if he.origin == vertex { + let he_id = HalfEdgeId(i as u32); + let angle = self.outgoing_angle(he_id); + fan.push((angle, he_id)); + } + } + + if fan.is_empty() { + return; + } + + // Sort by angle CCW + fan.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + + // Relink: twin(fan[i]).next = fan[(i+1) % n] + let n = fan.len(); + for i in 0..n { + let cur_he = fan[i].1; + let next_he = fan[(i + 1) % n].1; + let cur_twin = self.half_edges[cur_he.idx()].twin; + self.half_edges[cur_twin.idx()].next = next_he; + self.half_edges[next_he.idx()].prev = cur_twin; + } + + self.vertices[vertex.idx()].outgoing = fan[0].1; + } + + /// Merge vertex `v_remove` into `v_keep`. Both must be at the same position + /// (or close enough). All half-edges originating from `v_remove` are re-homed + /// to `v_keep`, and the combined fan is re-sorted by angle. + pub fn merge_vertices(&mut self, v_keep: VertexId, v_remove: VertexId) { + if v_keep == v_remove { + return; + } + debug_assert!(!self.vertices[v_keep.idx()].outgoing.is_none()); + debug_assert!(!self.vertices[v_remove.idx()].outgoing.is_none()); + + // Re-home all half-edges from v_remove to v_keep + let start = self.vertices[v_remove.idx()].outgoing; + let mut cur = start; + loop { + self.half_edges[cur.idx()].origin = v_keep; + let twin = self.half_edges[cur.idx()].twin; + cur = self.half_edges[twin.idx()].next; + if cur == start { + break; + } + } + + self.vertices[v_remove.idx()].outgoing = HalfEdgeId::NONE; + self.vertices[v_remove.idx()].deleted = true; + self.free_vertices.push(v_remove.0); + + // Rebuild the combined fan at v_keep + self.rebuild_vertex_fan(v_keep); + self.vertex_rtree = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Point; + + fn line_curve(p0: Point, p1: Point) -> CubicBez { + let c1 = super::super::lerp_point(p0, p1, 1.0 / 3.0); + let c2 = super::super::lerp_point(p0, p1, 2.0 / 3.0); + CubicBez::new(p0, c1, c2, p1) + } + + #[test] + fn insert_single_edge() { + let mut dcel = Dcel::new(); + let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0)); + let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0)); + let curve = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)); + + let (edge_id, face) = dcel.insert_edge(v1, v2, FaceId(0), curve); + assert!(!edge_id.is_none()); + assert_eq!(face, FaceId(0)); + + // Both half-edges should form a 2-cycle + let [he_fwd, he_bwd] = dcel.edges[edge_id.idx()].half_edges; + assert_eq!(dcel.half_edges[he_fwd.idx()].next, he_bwd); + assert_eq!(dcel.half_edges[he_bwd.idx()].next, he_fwd); + assert_eq!(dcel.half_edges[he_fwd.idx()].origin, v1); + assert_eq!(dcel.half_edges[he_bwd.idx()].origin, v2); + } + + #[test] + fn insert_spur() { + let mut dcel = Dcel::new(); + let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0)); + let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0)); + let v3 = dcel.alloc_vertex(Point::new(10.0, 10.0)); + + let c1 = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)); + let c2 = line_curve(Point::new(10.0, 0.0), Point::new(10.0, 10.0)); + + dcel.insert_edge(v1, v2, FaceId(0), c1); + let (e2, _) = dcel.insert_edge(v2, v3, FaceId(0), c2); + + // v3 should have outgoing pointing back toward v2 + let v3_out = dcel.vertices[v3.idx()].outgoing; + assert!(!v3_out.is_none()); + assert_eq!(dcel.half_edges[v3_out.idx()].origin, v3); + + // Edge should exist + assert!(!e2.is_none()); + } + + #[test] + fn insert_triangle_no_face_in_f0() { + let mut dcel = Dcel::new(); + let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0)); + let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0)); + let v3 = dcel.alloc_vertex(Point::new(5.0, 10.0)); + + let c1 = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)); + let c2 = line_curve(Point::new(10.0, 0.0), Point::new(5.0, 10.0)); + let c3 = line_curve(Point::new(5.0, 10.0), Point::new(0.0, 0.0)); + + dcel.insert_edge(v1, v2, FaceId(0), c1); + dcel.insert_edge(v2, v3, FaceId(0), c2); + let (_e3, face) = dcel.insert_edge(v3, v1, FaceId(0), c3); + + // In F0, closing a triangle should NOT create a new face + assert_eq!(face, FaceId(0)); + } + + #[test] + fn split_edge_creates_vertex() { + let mut dcel = Dcel::new(); + let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0)); + let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0)); + let curve = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)); + + let (edge_id, _) = dcel.insert_edge(v1, v2, FaceId(0), curve); + let (new_v, new_e) = dcel.split_edge(edge_id, 0.5); + + // New vertex should be near (5, 0) + let pos = dcel.vertices[new_v.idx()].position; + assert!((pos.x - 5.0).abs() < 0.1); + assert!((pos.y - 0.0).abs() < 0.1); + + // Should now have 2 edges + assert!(!new_e.is_none()); + assert_ne!(edge_id, new_e); + } + + #[test] + fn remove_edge_basic() { + let mut dcel = Dcel::new(); + let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0)); + let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0)); + let curve = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)); + + let (edge_id, _) = dcel.insert_edge(v1, v2, FaceId(0), curve); + let surviving = dcel.remove_edge(edge_id); + + assert_eq!(surviving, FaceId(0)); + assert!(dcel.vertices[v1.idx()].outgoing.is_none()); + assert!(dcel.vertices[v2.idx()].outgoing.is_none()); + assert!(dcel.edges[edge_id.idx()].deleted); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 4902e53..977f274 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -9,7 +9,7 @@ use crate::layer::VectorLayer; use crate::shape::Shape; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape}; +use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape}; /// Result of a hit test operation #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -216,40 +216,6 @@ pub fn hit_test_dcel_in_rect( result } -/// Classification of shapes relative to a clipping region -#[derive(Debug, Clone)] -pub struct ShapeRegionClassification { - /// Shapes entirely inside the region - pub fully_inside: Vec, - /// Shapes whose paths cross the region boundary - pub intersecting: Vec, - /// Shapes with no overlap with the region - pub fully_outside: Vec, -} - -/// Classify shapes in a layer relative to a clipping region. -/// -/// Uses bounding box fast-rejection, then checks path-region intersection -/// and containment for accurate classification. -pub fn classify_shapes_by_region( - layer: &VectorLayer, - time: f64, - region: &BezPath, - parent_transform: Affine, -) -> ShapeRegionClassification { - let result = ShapeRegionClassification { - fully_inside: Vec::new(), - intersecting: Vec::new(), - fully_outside: Vec::new(), - }; - - let region_bbox = region.bounding_box(); - - // TODO: Implement DCEL-based region classification - let _ = (layer, time, parent_transform, region_bbox); - - result -} /// Get the bounding box of a shape in screen space pub fn get_shape_bounds( diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index fc7a3cb..9eb9e54 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -44,7 +44,8 @@ pub mod file_io; pub mod export; pub mod clipboard; pub mod region_select; -pub mod dcel; +pub mod dcel2; +pub use dcel2 as dcel; pub mod snap; #[cfg(debug_assertions)] diff --git a/lightningbeam-ui/lightningbeam-core/src/region_select.rs b/lightningbeam-ui/lightningbeam-core/src/region_select.rs index a64f4c8..0ca8098 100644 --- a/lightningbeam-ui/lightningbeam-core/src/region_select.rs +++ b/lightningbeam-ui/lightningbeam-core/src/region_select.rs @@ -287,7 +287,7 @@ struct Crossing { // ── Core clipping ──────────────────────────────────────────────────────── /// Convert a line segment to a CubicBez -fn line_to_cubic(line: &Line) -> CubicBez { +pub fn line_to_cubic(line: &Line) -> CubicBez { let p0 = line.p0; let p1 = line.p1; let cp1 = Point::new( diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index cfdf76b..1701ca8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -6,7 +6,7 @@ use crate::dcel::{Dcel, EdgeId, FaceId, VertexId}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use uuid::Uuid; -use vello::kurbo::BezPath; +use vello::kurbo::{Affine, BezPath}; /// Selection state for the editor /// @@ -271,9 +271,11 @@ impl Selection { /// Represents a temporary region-based selection. /// -/// When a region select is active, elements that cross the region boundary -/// are tracked. If the user performs an operation, the selection is -/// committed; if they deselect, the original state is restored. +/// When a region select is active, the region boundary is inserted into the +/// DCEL as invisible edges, splitting existing geometry. Faces inside the +/// region are added to the normal `Selection`. If the user performs an +/// operation, the selection is committed; if they deselect, the DCEL is +/// restored from the snapshot. #[derive(Clone, Debug)] pub struct RegionSelection { /// The clipping region as a closed BezPath (polygon or rect) @@ -282,10 +284,12 @@ pub struct RegionSelection { pub layer_id: Uuid, /// Keyframe time pub time: f64, - /// Per-shape split results (legacy, kept for compatibility) - pub splits: Vec<()>, - /// IDs that were fully inside the region - pub fully_inside_ids: Vec, + /// Snapshot of the DCEL before region boundary insertion, for revert + pub dcel_snapshot: Dcel, + /// The extracted DCEL containing geometry inside the region + pub selected_dcel: Dcel, + /// Transform applied to the selected DCEL (e.g. from dragging) + pub transform: Affine, /// Whether the selection has been committed (via an operation on the selection) pub committed: bool, } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index ec99a2e..b007596 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -824,6 +824,19 @@ impl egui_wgpu::CallbackTrait for VelloCallback { ); } + // Render selected DCEL from active region selection (with transform) + if let Some(ref region_sel) = self.ctx.region_selection { + let sel_transform = camera_transform * region_sel.transform; + lightningbeam_core::renderer::render_dcel( + ®ion_sel.selected_dcel, + &mut scene, + sel_transform, + 1.0, + &self.ctx.document, + &mut image_cache, + ); + } + drop(image_cache); scene }; @@ -1007,6 +1020,50 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // 1a. Draw stipple overlay on region-selected DCEL + if let Some(ref region_sel) = self.ctx.region_selection { + use lightningbeam_core::dcel::FaceId as DcelFaceId; + let sel_dcel = ®ion_sel.selected_dcel; + let sel_transform = overlay_transform * region_sel.transform; + let stipple_brush = selection_stipple_brush(); + let inv_zoom = 1.0 / self.ctx.zoom as f64; + let brush_xform = Some(Affine::scale(inv_zoom)); + + // Stipple faces with visible fill + for (i, face) in sel_dcel.faces.iter().enumerate() { + if face.deleted || i == 0 { continue; } + if face.fill_color.is_none() && face.image_fill.is_none() { continue; } + let face_id = DcelFaceId(i as u32); + let path = sel_dcel.face_to_bezpath_with_holes(face_id); + scene.fill( + vello::peniko::Fill::NonZero, + sel_transform, + stipple_brush, + brush_xform, + &path, + ); + } + + // Stipple edges with visible stroke + for edge in &sel_dcel.edges { + if edge.deleted { continue; } + if edge.stroke_style.is_none() && edge.stroke_color.is_none() { continue; } + let width = edge.stroke_style.as_ref() + .map(|s| s.width) + .unwrap_or(2.0); + let mut path = vello::kurbo::BezPath::new(); + path.move_to(edge.curve.p0); + path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3); + scene.stroke( + &vello::kurbo::Stroke::new(width), + sel_transform, + stipple_brush, + brush_xform, + &path, + ); + } + } + // 1b. Draw stipple hover highlight on the curve under the mouse // During active curve editing, lock highlight to the edited curve if matches!(self.ctx.selected_tool, Tool::Select | Tool::BezierEdit) { @@ -3676,59 +3733,107 @@ impl StagePane { } } - /// Execute region selection: classify shapes, clip intersecting ones, create temporary split + /// Execute region selection: snapshot DCEL, insert region boundary, extract inside geometry fn execute_region_select( shared: &mut SharedPaneState, region_path: vello::kurbo::BezPath, layer_id: uuid::Uuid, ) { - use lightningbeam_core::hit_test; use lightningbeam_core::layer::AnyLayer; - use vello::kurbo::Affine; + use lightningbeam_core::region_select::line_to_cubic; + use vello::kurbo::Line; let time = *shared.playback_time; - // Classify shapes - let classification = { - let document = shared.action_executor.document(); - let layer = match document.get_layer(&layer_id) { - Some(l) => l, + // Get mutable DCEL and snapshot it before insertion + let document = shared.action_executor.document_mut(); + let dcel = match document.get_layer_mut(&layer_id) { + Some(AnyLayer::Vector(vl)) => match vl.dcel_at_time_mut(time) { + Some(d) => d, None => return, - }; - let vector_layer = match layer { - AnyLayer::Vector(vl) => vl, - _ => return, - }; - hit_test::classify_shapes_by_region(vector_layer, time, ®ion_path, Affine::IDENTITY) + }, + _ => return, }; - // If nothing is inside or intersecting, do nothing - if classification.fully_inside.is_empty() && classification.intersecting.is_empty() { + let snapshot = dcel.clone(); + + // Convert region path line segments to CubicBez for insert_stroke + let segments: Vec<_> = { + let mut segs = Vec::new(); + let mut current = vello::kurbo::Point::ZERO; + let mut subpath_start = vello::kurbo::Point::ZERO; + for el in region_path.elements() { + match *el { + vello::kurbo::PathEl::MoveTo(p) => { + current = p; + subpath_start = p; + } + vello::kurbo::PathEl::LineTo(p) => { + segs.push(line_to_cubic(&Line::new(current, p))); + current = p; + } + vello::kurbo::PathEl::ClosePath => { + if current.distance(subpath_start) > 1e-10 { + segs.push(line_to_cubic(&Line::new(current, subpath_start))); + } + current = subpath_start; + } + vello::kurbo::PathEl::CurveTo(p1, p2, p3) => { + segs.push(vello::kurbo::CubicBez::new(current, p1, p2, p3)); + current = p3; + } + vello::kurbo::PathEl::QuadTo(_p1, p2) => { + segs.push(line_to_cubic(&Line::new(current, p2))); + current = p2; + } + } + } + segs + }; + + if segments.is_empty() { + return; + } + + // Insert region boundary as invisible edges (no stroke style/color) + let stroke_result = dcel.insert_stroke(&segments, None, None, 1.0); + let boundary_verts = stroke_result.new_vertices; + + // Extract the inside portion; self (dcel) keeps the outside + boundary. + let mut selected_dcel = dcel.extract_region(®ion_path, &boundary_verts); + + // Propagate fills ONLY on the extracted DCEL. The remainder (dcel) already + // has correct fills from the original data — its filled faces (e.g., the + // L-shaped remainder) keep their fill, and merged faces from edge removal + // correctly have no fill. Running propagate_fills on the remainder would + // incorrectly add fill to merged faces that span filled and unfilled areas. + selected_dcel.propagate_fills(&snapshot, ®ion_path, &boundary_verts); + + // Check if the extracted DCEL has any visible content + let has_visible = selected_dcel.edges.iter().any(|e| !e.deleted && (e.stroke_style.is_some() || e.stroke_color.is_some())) + || selected_dcel.faces.iter().enumerate().any(|(i, f)| !f.deleted && i > 0 && (f.fill_color.is_some() || f.image_fill.is_some())); + + if !has_visible { + // Nothing visible inside — restore snapshot and bail + *dcel = snapshot; return; } shared.selection.clear(); - // TODO: DCEL - region selection element selection deferred to Phase 2 - - // For intersecting shapes: compute clip and create temporary splits - let splits = Vec::new(); - - // TODO: DCEL - region selection shape splitting disabled during migration - // (was: get_shape_in_keyframe for intersecting shapes, clip paths, add/remove_shape_from_keyframe) - - // Store region selection state + // Store region selection state with extracted DCEL *shared.region_selection = Some(lightningbeam_core::selection::RegionSelection { region_path, layer_id, time, - splits, - fully_inside_ids: classification.fully_inside, + dcel_snapshot: snapshot, + selected_dcel, + transform: vello::kurbo::Affine::IDENTITY, committed: false, }); } - /// Revert an uncommitted region selection, restoring original shapes + /// Revert an uncommitted region selection, restoring the DCEL from snapshot fn revert_region_selection_static(shared: &mut SharedPaneState) { use lightningbeam_core::layer::AnyLayer; @@ -3742,21 +3847,15 @@ impl StagePane { return; } + // Restore the DCEL from the snapshot taken before boundary insertion let doc = shared.action_executor.document_mut(); - let layer = match doc.get_layer_mut(®ion_sel.layer_id) { - Some(l) => l, - None => return, - }; - let vector_layer = match layer { - AnyLayer::Vector(vl) => vl, - _ => return, - }; + if let Some(AnyLayer::Vector(vl)) = doc.get_layer_mut(®ion_sel.layer_id) { + if let Some(dcel) = vl.dcel_at_time_mut(region_sel.time) { + *dcel = region_sel.dcel_snapshot; + } + } - // TODO: DCEL - region selection revert disabled during migration - // (was: remove_shape_from_keyframe for splits, add_shape_to_keyframe to restore originals) - let _ = vector_layer; - - shared.selection.clear(); + shared.selection.clear_dcel_selection(); } /// Create a rectangle path centered at origin (easier for curve editing later)