From 9edfc2086a6dab54326efe50522636e5f70e8a99 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 1 Mar 2026 00:35:02 -0500 Subject: [PATCH] work on dcel --- .../src/actions/add_shape.rs | 22 ++++++- .../lightningbeam-core/src/dcel2/query.rs | 17 +++++ .../lightningbeam-core/src/dcel2/stroke.rs | 64 +++++++++++++++++++ .../lightningbeam-core/src/dcel2/topology.rs | 9 ++- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs index cc4a48a..b00370c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs @@ -4,7 +4,7 @@ //! `Dcel::insert_stroke()`. Undo is handled by snapshotting the DCEL. use crate::action::Action; -use crate::dcel::{bezpath_to_cubic_segments, Dcel, DEFAULT_SNAP_EPSILON}; +use crate::dcel::{bezpath_to_cubic_segments, Dcel, FaceId, DEFAULT_SNAP_EPSILON}; use crate::document::Document; use crate::layer::AnyLayer; use crate::shape::{ShapeColor, StrokeStyle}; @@ -87,8 +87,24 @@ impl Action for AddShapeAction { // Apply fill to new faces if this is a closed shape with fill if self.is_closed { if let Some(ref fill) = self.fill_color { - for face_id in &result.new_faces { - dcel.face_mut(*face_id).fill_color = Some(fill.clone()); + if !result.new_faces.is_empty() { + for face_id in &result.new_faces { + dcel.face_mut(*face_id).fill_color = Some(fill.clone()); + } + } else if let Some(&first_edge) = result.new_edges.first() { + // Closed shape in F0 — no face was auto-created. + // One half-edge of the first new edge is on the interior cycle. + // Pick the side with positive signed area (CCW winding). + let [he_a, he_b] = dcel.edge(first_edge).half_edges; + let interior_he = if dcel.cycle_signed_area(he_a) > 0.0 { + he_a + } else { + he_b + }; + if dcel.half_edge(interior_he).face == FaceId(0) { + let face_id = dcel.create_face_at_cycle(interior_he); + dcel.face_mut(face_id).fill_color = Some(fill.clone()); + } } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs index b70a7b3..bb61f31 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs @@ -32,6 +32,23 @@ impl Dcel { result } + /// Compute the signed area of the cycle starting at `start`. + /// Positive = CCW (interior), negative = CW (exterior). + pub fn cycle_signed_area(&self, start: HalfEdgeId) -> f64 { + let mut area = 0.0; + let mut cur = start; + loop { + let p0 = self.vertices[self.half_edges[cur.idx()].origin.idx()].position; + cur = self.half_edges[cur.idx()].next; + let p1 = self.vertices[self.half_edges[cur.idx()].origin.idx()].position; + area += p0.x * p1.y - p1.x * p0.y; + if cur == start { + break; + } + } + area * 0.5 + } + /// 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; diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs index d7e4a03..596f776 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs @@ -874,6 +874,70 @@ mod tests { dcel.validate(); } + /// Rectangle with a face, then a stroke drawn across it. + /// The stroke should split two rectangle edges and create sub-edges + /// inside and outside the face. All face assignments must be consistent. + #[test] + fn stroke_across_filled_rectangle() { + let mut dcel = Dcel::new(); + + // Insert rectangle as 4 line segments (like bezpath_to_cubic_segments would) + let r = 100.0; + let segs = [ + // bottom: (0,0) → (r,0) + CubicBez::new( + Point::new(0.0, 0.0), Point::new(r / 3.0, 0.0), + Point::new(2.0 * r / 3.0, 0.0), Point::new(r, 0.0), + ), + // right: (r,0) → (r,r) + CubicBez::new( + Point::new(r, 0.0), Point::new(r, r / 3.0), + Point::new(r, 2.0 * r / 3.0), Point::new(r, r), + ), + // top: (r,r) → (0,r) + CubicBez::new( + Point::new(r, r), Point::new(2.0 * r / 3.0, r), + Point::new(r / 3.0, r), Point::new(0.0, r), + ), + // left: (0,r) → (0,0) + CubicBez::new( + Point::new(0.0, r), Point::new(0.0, 2.0 * r / 3.0), + Point::new(0.0, r / 3.0), Point::new(0.0, 0.0), + ), + ]; + + let rect_result = dcel.insert_stroke(&segs, None, None, 1.0); + println!("Rectangle: {} edges, {} vertices", + rect_result.new_edges.len(), rect_result.new_vertices.len()); + + // Create a face on the interior cycle (like add_shape does) + let first_edge = rect_result.new_edges[0]; + let [he_a, he_b] = dcel.edge(first_edge).half_edges; + let interior_he = if dcel.cycle_signed_area(he_a) > 0.0 { he_a } else { he_b }; + let face = dcel.create_face_at_cycle(interior_he); + println!("Created face {:?}", face); + + dcel.validate(); + + // Now draw a horizontal stroke across the rectangle at y=50 + // from x=-50 to x=150 (extending beyond both sides) + let stroke = CubicBez::new( + Point::new(-50.0, 50.0), Point::new(16.0, 50.0), + Point::new(83.0, 50.0), Point::new(150.0, 50.0), + ); + + let stroke_result = dcel.insert_stroke(&[stroke], None, None, 1.0); + println!("Stroke: {} edges, {} splits, {} vertices", + stroke_result.new_edges.len(), stroke_result.split_edges.len(), + stroke_result.new_vertices.len()); + + // Should have split 2 rectangle edges (left and right sides) + assert_eq!(stroke_result.split_edges.len(), 2, + "stroke should cross left and right sides of rectangle"); + + dcel.validate(); + } + #[test] fn insert_stroke_self_intersecting_segment() { let mut dcel = Dcel::new(); diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs index e077933..a7e5cc8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs @@ -224,7 +224,14 @@ impl Dcel { 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; + let face_v1 = self.half_edges[into_v1.idx()].face; + let face_v2 = self.half_edges[into_v2.idx()].face; + debug_assert_eq!( + face_v1, face_v2, + "insert_edge_both_connected: into_v1 (HE{}) on {:?} but into_v2 (HE{}) on {:?}", + into_v1.0, face_v1, into_v2.0, face_v2 + ); + let actual_face = face_v1; // Splice: // into_v1 → he_fwd → ccw_v2 → ...