diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs index bb61f31..95f714f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/query.rs @@ -49,6 +49,59 @@ impl Dcel { area * 0.5 } + /// Compute the signed area of the cycle using the actual Bézier curves, + /// not just vertex positions. Uses Green's theorem: A = ½ ∫(x dy - y dx). + /// For a cubic B(t) = (x(t), y(t)), the integral is evaluated numerically. + pub fn cycle_curve_signed_area(&self, start: HalfEdgeId) -> f64 { + let mut area = 0.0; + let mut cur = start; + loop { + let edge_id = self.half_edges[cur.idx()].edge; + let edge = &self.edges[edge_id.idx()]; + let [fwd, _bwd] = edge.half_edges; + let curve = if cur == fwd { + edge.curve + } else { + // Reverse the curve for backward half-edge + kurbo::CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0) + }; + + // Numerical integration of ½(x dy - y dx) using Simpson's rule + let n = 16; + let dt = 1.0 / n as f64; + for i in 0..n { + let t0 = i as f64 * dt; + let t1 = (i as f64 + 0.5) * dt; + let t2 = (i as f64 + 1.0) * dt; + + let p0 = curve.eval(t0); + let p1 = curve.eval(t1); + let p2 = curve.eval(t2); + + // Simpson's rule for the integrand x*dy/dt - y*dx/dt + // Approximate dx, dy from finite differences + let dx0 = (p1.x - p0.x) / (dt * 0.5); + let dy0 = (p1.y - p0.y) / (dt * 0.5); + let dx1 = (p2.x - p0.x) / dt; + let dy1 = (p2.y - p0.y) / dt; + let dx2 = (p2.x - p1.x) / (dt * 0.5); + let dy2 = (p2.y - p1.y) / (dt * 0.5); + + let f0 = p0.x * dy0 - p0.y * dx0; + let f1 = p1.x * dy1 - p1.y * dx1; + let f2 = p2.x * dy2 - p2.y * dx2; + + area += (f0 + 4.0 * f1 + f2) * dt / 6.0; + } + + cur = self.half_edges[cur.idx()].next; + 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 596f776..99a4443 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs @@ -8,9 +8,9 @@ //! different intersection positions for the same crossing. use super::{ - subsegment_cubic, Dcel, EdgeId, FaceId, HalfEdgeId, VertexId, DEFAULT_SNAP_EPSILON, + subsegment_cubic, Dcel, EdgeId, FaceId, HalfEdgeId, VertexId, }; -use crate::curve_intersections::{find_curve_intersections, Intersection}; +use crate::curve_intersections::find_curve_intersections; use crate::shape::{ShapeColor, StrokeStyle}; use kurbo::{CubicBez, ParamCurve, Point}; @@ -375,6 +375,7 @@ impl Dcel { // Splits inserted cv twice without maintaining the CCW fan — fix it self.rebuild_vertex_fan(cv); + self.repair_face_cycles_at_vertex(cv); } // 2. Check against all other edges @@ -543,7 +544,7 @@ mod tests { // 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; + 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 @@ -559,7 +560,7 @@ mod tests { // 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() { + 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; @@ -958,4 +959,196 @@ mod tests { dcel.validate(); } + + /// After moving a vertex (simulating EditingVertex), the CCW fan ordering + /// must be rebuilt before inserting new strokes. Without rebuild_vertex_fan, + /// the stale angular ordering causes face/cycle mismatches. + #[test] + fn stroke_after_vertex_move() { + let mut dcel = Dcel::new(); + + // Build a rectangle and create a face + let r = 100.0; + let segs = [ + 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), + ), + 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), + ), + 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), + ), + 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); + + 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); + + dcel.validate(); + + // Simulate dragging the top-right vertex (r, r) → (r + 30, r + 20). + // This is what finish_vector_editing does for EditingVertex: + // 1. Update vertex position + // 2. Update adjacent edge curves + // 3. Rebuild fans at affected vertices + let moved_vertex = { + // Find the vertex at (r, r) + let vid = dcel.snap_vertex(Point::new(r, r), 1.0).unwrap(); + let new_pos = Point::new(r + 30.0, r + 20.0); + + // Move the vertex + dcel.vertex_mut(vid).position = new_pos; + + // Update the curves of connected edges to match the new position. + // Collect edge info first to avoid borrow issues. + let outgoing: Vec<_> = dcel.vertex_outgoing(vid) + .iter() + .map(|&he_id| { + let edge_id = dcel.half_edge(he_id).edge; + let [fwd, _bwd] = dcel.edge(edge_id).half_edges; + let is_fwd = fwd == he_id; + (edge_id, is_fwd) + }) + .collect(); + + for (edge_id, is_fwd) in outgoing { + let curve = &mut dcel.edge_mut(edge_id).curve; + if is_fwd { + // This vertex is the origin of the forward half-edge (p0) + let old_p0 = curve.p0; + let delta = new_pos - old_p0; + curve.p0 = new_pos; + curve.p1 = curve.p1 + delta; + } else { + // This vertex is the origin of the backward half-edge (p3) + let old_p3 = curve.p3; + let delta = new_pos - old_p3; + curve.p3 = new_pos; + curve.p2 = curve.p2 + delta; + } + } + + vid + }; + + // Rebuild fans at the moved vertex and its neighbors — the fix under test + dcel.rebuild_vertex_fan(moved_vertex); + for &he_id in &dcel.vertex_outgoing(moved_vertex) { + let edge_id = dcel.half_edge(he_id).edge; + let [fwd, bwd] = dcel.edge(edge_id).half_edges; + let neighbor = if dcel.half_edge(fwd).origin == moved_vertex { + dcel.half_edge(bwd).origin + } else { + dcel.half_edge(fwd).origin + }; + dcel.rebuild_vertex_fan(neighbor); + } + + // Recompute intersections on connected edges + let connected_edges: Vec<_> = dcel.vertex_outgoing(moved_vertex) + .iter() + .map(|&he_id| dcel.half_edge(he_id).edge) + .collect(); + for eid in connected_edges { + dcel.recompute_edge_intersections(eid); + } + + dcel.validate(); + + // Now insert a stroke across — this would crash with stale fan ordering + let stroke = CubicBez::new( + Point::new(-50.0, 50.0), Point::new(16.0, 50.0), + Point::new(83.0, 50.0), Point::new(200.0, 50.0), + ); + let _stroke_result = dcel.insert_stroke(&[stroke], None, None, 1.0); + + dcel.validate(); + } + + #[test] + fn self_intersection_splits_face() { + use crate::shape::ShapeColor; + + let mut dcel = Dcel::new(); + + // Build a rectangle and create a filled face + let r = 100.0; + let segs = [ + 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), + ), + 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), + ), + 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), + ), + 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); + + 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); + dcel.faces[face.idx()].fill_color = Some(ShapeColor::rgb(0, 0, 255)); + + dcel.validate(); + + // Replace the bottom edge curve with one that self-intersects. + // The known-working loop: p0=(0,0), p1=(200,100), p2=(-100,100), p3=(100,0) + // Bottom edge goes (0,0)→(100,0), same x-range, both y=0. + let bottom_edge = rect_result.new_edges[0]; + dcel.edges[bottom_edge.idx()].curve = CubicBez::new( + Point::new(0.0, 0.0), + Point::new(200.0, 100.0), + Point::new(-100.0, 100.0), + Point::new(r, 0.0), + ); + // Verify the curve actually self-intersects + assert!( + Dcel::find_cubic_self_intersection(&dcel.edges[bottom_edge.idx()].curve).is_some(), + "test curve should self-intersect", + ); + + // Recompute intersections — should detect self-intersection and split + let _created = dcel.recompute_edge_intersections(bottom_edge); + + // Should now have more faces because the self-intersection created a loop + let non_f0_faces: Vec<_> = dcel + .faces + .iter() + .enumerate() + .filter(|(i, f)| *i != 0 && !f.deleted) + .collect(); + + // The loop should have been detected and either: + // - Created as a new face (if positive area in F0) + // - Split the existing face (if same face had 2 cycles) + assert!( + non_f0_faces.len() >= 2, + "expected at least 2 non-F0 faces after self-intersection split, got {}", + non_f0_faces.len() + ); + + dcel.validate(); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs index a7e5cc8..edbb937 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs @@ -119,7 +119,7 @@ impl Dcel { 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_both_connected(he_fwd, he_bwd, v1, v2, edge_id, &curve, face) } _ => { self.insert_edge_one_isolated(he_fwd, he_bwd, v1, v2, edge_id, &curve, v1_isolated) @@ -206,6 +206,10 @@ impl Dcel { } /// Both vertices connected: may split a face. + /// + /// `face_hint` is the face the caller expects the edge to be in. + /// If the angular ordering places the edge in F0 but `face_hint` is + /// a non-F0 face, the opposite angular sector is tried. fn insert_edge_both_connected( &mut self, he_fwd: HalfEdgeId, @@ -214,6 +218,7 @@ impl Dcel { v2: VertexId, edge_id: EdgeId, curve: &CubicBez, + face_hint: FaceId, ) -> (EdgeId, FaceId) { let fwd_angle = Self::curve_start_angle(curve); let bwd_angle = Self::curve_end_angle(curve); @@ -226,12 +231,31 @@ impl Dcel { 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; + + // If the angular ordering places both predecessors in F0 but the + // caller expects a non-F0 face, try the opposite sector: use + // `ccw_v1` and `ccw_v2`'s twins to find the other sector at each vertex. + let (into_v1, into_v2, ccw_v1, ccw_v2, actual_face) = + if face_v1 == face_v2 && face_v1.0 == 0 && face_hint.0 != 0 { + // Try the opposite sector: at each vertex, the predecessor + // of the OTHER outgoing edge in the face_hint cycle. + let alt = self.find_predecessor_on_face(v1, fwd_angle, face_hint) + .zip(self.find_predecessor_on_face(v2, bwd_angle, face_hint)); + if let Some(((alt_into_v1, alt_ccw_v1), (alt_into_v2, alt_ccw_v2))) = alt { + (alt_into_v1, alt_into_v2, alt_ccw_v1, alt_ccw_v2, face_hint) + } else { + debug_assert_eq!(face_v1, face_v2); + (into_v1, into_v2, ccw_v1, ccw_v2, face_v1) + } + } else { + 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 + ); + (into_v1, into_v2, ccw_v1, ccw_v2, face_v1) + }; + let actual_face = actual_face; // Splice: // into_v1 → he_fwd → ccw_v2 → ... @@ -295,6 +319,49 @@ impl Dcel { (edge_id, new_face) } + /// Find the predecessor and CCW-successor half-edges in the fan at `vertex` + /// that belong to a specific face. Returns `(into_he, ccw_successor)` or + /// `None` if no sector at `vertex` belongs to the given face. + /// + /// `into_he` is the HE arriving at `vertex` on the target face's cycle. + /// `ccw_successor` is the next outgoing HE from `vertex` in the same face's cycle. + fn find_predecessor_on_face( + &self, + vertex: VertexId, + _angle: f64, + face: FaceId, + ) -> Option<(HalfEdgeId, HalfEdgeId)> { + let start = self.vertices[vertex.idx()].outgoing; + if start.is_none() { + return None; + } + + // Walk the fan at this vertex. For each outgoing HE `cur`, its twin + // arrives at vertex. twin.next is the next outgoing HE in CCW order. + // The sector between `cur` (outgoing) and the previous outgoing + // (i.e., twin of the previous HE) has the face of `prev_twin`. + // Equivalently: twin of `cur` is on the same face as the sector + // between `cur` and the next outgoing. + let mut cur = start; + loop { + let twin = self.half_edges[cur.idx()].twin; + // The face of `twin` is the face of the sector between `cur` + // (this outgoing) and the next outgoing (twin.next). + if self.half_edges[twin.idx()].face == face { + // Found it: `twin` arrives at vertex on the target face, + // and `twin.next` (= next outgoing) leaves on the target face. + let next_outgoing = self.half_edges[twin.idx()].next; + return Some((twin, next_outgoing)); + } + let next = self.half_edges[twin.idx()].next; + if next == start { + break; + } + cur = next; + } + None + } + /// 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; @@ -594,6 +661,200 @@ impl Dcel { self.vertices[vertex.idx()].outgoing = fan[0].1; } + /// After `rebuild_vertex_fan` re-links `next`/`prev` pointers at a vertex, + /// face assignments may be wrong in two ways: + /// + /// 1. **Multiple cycles per face**: A face's single boundary was split + /// into two separate cycles. Each extra cycle gets a new face. + /// + /// 2. **Pinched cycle**: A face's boundary visits a vertex more than + /// once ("figure-8" or "lollipop" shape). The cycle is split at the + /// repeated vertex into sub-cycles, each becoming its own face. + /// + /// Returns the list of newly created faces. + pub fn repair_face_cycles_at_vertex(&mut self, vertex: VertexId) -> Vec { + let outgoing = self.vertex_outgoing(vertex); + if outgoing.is_empty() { + return Vec::new(); + } + + use std::collections::HashMap; + let mut new_faces = Vec::new(); + + // --- Phase 1: Detect and split pinched cycles --- + // + // Walk ALL cycles touching this vertex. When a cycle visits a vertex + // twice (pinch), extract the loop sub-path as a new cycle. + // If the loop has positive area, create a new face (inheriting fill + // from an adjacent non-F0 face if the parent cycle is F0). + + // Collect unique cycle start HEs touching this vertex + let mut cycle_starts: Vec = Vec::new(); + let mut seen_cycle_reps: Vec = Vec::new(); + for &he in &outgoing { + for start in [he, self.half_edges[he.idx()].twin] { + let cycle = self.walk_cycle(start); + let rep = cycle.iter().copied().min_by_key(|h| h.0).unwrap(); + if !seen_cycle_reps.contains(&rep) { + seen_cycle_reps.push(rep); + cycle_starts.push(start); + } + } + } + + for cycle_start in cycle_starts { + // Walk the cycle vertex-by-vertex, including one extra step + // to re-check the start vertex for a closing pinch. + let mut vertex_first_he: HashMap = HashMap::new(); + let mut cur = cycle_start; + let cycle_len = self.walk_cycle(cycle_start).len(); + let mut steps = 0; + let mut finished = false; + + loop { + let v = self.half_edges[cur.idx()].origin; + + if let Some(&first_he) = vertex_first_he.get(&v) { + // Pinch detected! Extract the loop (first_he..last_of_loop). + let prev_of_first = self.half_edges[first_he.idx()].prev; + let last_of_loop = self.half_edges[cur.idx()].prev; + + // Relink: close the loop and bridge the main cycle + self.half_edges[last_of_loop.idx()].next = first_he; + self.half_edges[first_he.idx()].prev = last_of_loop; + self.half_edges[prev_of_first.idx()].next = cur; + self.half_edges[cur.idx()].prev = prev_of_first; + + // Determine area of the extracted loop + let mut area = self.cycle_signed_area(first_he); + if area.abs() < 1e-6 { + area = self.cycle_curve_signed_area(first_he); + } + + // Find a non-F0 donor face at the pinch vertex + let donor_face = if area > 0.0 { + let mut df = FaceId(0); + for he_rec in self.half_edges.iter() { + if he_rec.deleted { continue; } + if he_rec.origin == v { + if he_rec.face.0 != 0 { + df = he_rec.face; + break; + } + let tf = self.half_edges[he_rec.twin.idx()].face; + if tf.0 != 0 { + df = tf; + break; + } + } + } + df + } else { + FaceId(0) + }; + + if area > 0.0 && donor_face.0 != 0 { + let nf = self.alloc_face(); + self.faces[nf.idx()].fill_color = + self.faces[donor_face.idx()].fill_color; + self.faces[nf.idx()].image_fill = + self.faces[donor_face.idx()].image_fill; + self.faces[nf.idx()].fill_rule = + self.faces[donor_face.idx()].fill_rule; + self.faces[nf.idx()].outer_half_edge = first_he; + self.assign_cycle_face(first_he, nf); + new_faces.push(nf); + } else { + // Undo the relink + self.half_edges[last_of_loop.idx()].next = cur; + self.half_edges[cur.idx()].prev = last_of_loop; + self.half_edges[prev_of_first.idx()].next = first_he; + self.half_edges[first_he.idx()].prev = prev_of_first; + } + + vertex_first_he.insert(v, cur); + } else { + vertex_first_he.insert(v, cur); + } + + if finished { + break; + } + + cur = self.half_edges[cur.idx()].next; + steps += 1; + if steps > cycle_len + 2 { + break; + } + // When we've come full circle, process cycle_start once + // more (to detect a closing pinch) then stop. + if cur == cycle_start { + finished = true; + } + } + } + + // --- Phase 2: Handle multiple separate cycles per face --- + // (This handles case 1: rebuild_vertex_fan split one cycle into two + // distinct cycles, without a pinch.) + let outgoing = self.vertex_outgoing(vertex); + let mut cycle_reps: Vec<(HalfEdgeId, FaceId)> = Vec::new(); + let mut seen_reps: Vec = Vec::new(); + + for &he in &outgoing { + for start in [he, self.half_edges[he.idx()].twin] { + let cycle = self.walk_cycle(start); + let rep = cycle.iter().copied().min_by_key(|h| h.0).unwrap(); + if !seen_reps.contains(&rep) { + let face = self.half_edges[start.idx()].face; + cycle_reps.push((rep, face)); + seen_reps.push(rep); + } + } + } + + let mut face_cycles: HashMap> = HashMap::new(); + for &(rep, face) in &cycle_reps { + face_cycles.entry(face).or_default().push(rep); + } + + for (&face, cycles) in &face_cycles { + if face.0 == 0 || cycles.len() <= 1 { + continue; + } + + let old_ohe = self.faces[face.idx()].outer_half_edge; + + for &cycle_rep in cycles { + let has_old_ohe = !old_ohe.is_none() + && (cycle_rep == old_ohe || self.cycle_contains(cycle_rep, old_ohe)); + if has_old_ohe { + self.assign_cycle_face(cycle_rep, face); + continue; + } + + let area = self.cycle_signed_area(cycle_rep); + + if area > 0.0 { + let nf = self.alloc_face(); + self.faces[nf.idx()].fill_color = self.faces[face.idx()].fill_color; + self.faces[nf.idx()].image_fill = self.faces[face.idx()].image_fill; + self.faces[nf.idx()].fill_rule = self.faces[face.idx()].fill_rule; + self.faces[nf.idx()].outer_half_edge = cycle_rep; + self.assign_cycle_face(cycle_rep, nf); + new_faces.push(nf); + } else { + self.assign_cycle_face(cycle_rep, face); + if !self.faces[face.idx()].inner_half_edges.contains(&cycle_rep) { + self.faces[face.idx()].inner_half_edges.push(cycle_rep); + } + } + } + } + + new_faces + } + /// 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. @@ -732,4 +993,75 @@ mod tests { assert!(dcel.vertices[v2.idx()].outgoing.is_none()); assert!(dcel.edges[edge_id.idx()].deleted); } + + /// Test that `repair_face_cycles_at_vertex` correctly splits a face + /// when `rebuild_vertex_fan` has broken one cycle into two. + #[test] + fn repair_face_cycles_splits_face() { + use crate::shape::ShapeColor; + + let mut dcel = Dcel::new(); + + // Build a rectangle manually: 6 vertices, 6 edges + // The rectangle has split points on the left and right sides + // to simulate the result of splitting edges at intersection points. + // + // v3 ---- v2 + // | | + // vL vR + // | | + // v0 ---- v1 + let v0 = dcel.alloc_vertex(Point::new(0.0, 0.0)); + let v1 = dcel.alloc_vertex(Point::new(100.0, 0.0)); + let v2 = dcel.alloc_vertex(Point::new(100.0, 100.0)); + let v3 = dcel.alloc_vertex(Point::new(0.0, 100.0)); + let vl = dcel.alloc_vertex(Point::new(0.0, 50.0)); + let vr = dcel.alloc_vertex(Point::new(100.0, 50.0)); + + // Insert edges forming the rectangle boundary (with split points) + // Bottom: v0 → v1 + dcel.insert_edge(v0, v1, FaceId(0), line_curve(Point::new(0.0, 0.0), Point::new(100.0, 0.0))); + // Right-bottom: v1 → vR + dcel.insert_edge(v1, vr, FaceId(0), line_curve(Point::new(100.0, 0.0), Point::new(100.0, 50.0))); + // Right-top: vR → v2 + dcel.insert_edge(vr, v2, FaceId(0), line_curve(Point::new(100.0, 50.0), Point::new(100.0, 100.0))); + // Top: v2 → v3 + dcel.insert_edge(v2, v3, FaceId(0), line_curve(Point::new(100.0, 100.0), Point::new(0.0, 100.0))); + // Left-top: v3 → vL + dcel.insert_edge(v3, vl, FaceId(0), line_curve(Point::new(0.0, 100.0), Point::new(0.0, 50.0))); + // Left-bottom: vL → v0 + dcel.insert_edge(vl, v0, FaceId(0), line_curve(Point::new(0.0, 50.0), Point::new(0.0, 0.0))); + + // Create a face on the CCW interior cycle + let he_opts = dcel.vertex_outgoing(v0); + let interior_he = he_opts + .iter() + .copied() + .find(|&he| dcel.cycle_signed_area(he) > 0.0) + .expect("should have a CCW cycle"); + let face = dcel.create_face_at_cycle(interior_he); + dcel.faces[face.idx()].fill_color = Some(ShapeColor::rgb(255, 0, 0)); + + dcel.validate(); + + // Now insert the cross edge vL → vR (splitting the face) + let cross_curve = line_curve(Point::new(0.0, 50.0), Point::new(100.0, 50.0)); + let (_, _returned_face) = dcel.insert_edge(vl, vr, face, cross_curve); + + // insert_edge_both_connected should have split the face + let filled_faces: Vec<_> = dcel + .faces + .iter() + .enumerate() + .filter(|(i, f)| *i != 0 && !f.deleted && f.fill_color.is_some()) + .collect(); + + assert!( + filled_faces.len() >= 2, + "expected at least 2 filled faces after cross edge, got {}", + filled_faces.len(), + ); + + dcel.validate(); + } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index b007596..0e3d87f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -2969,19 +2969,70 @@ impl StagePane { } }; - // If we were editing a curve, recompute intersections before snapshotting. - // This detects new crossings between the edited edge and other edges, - // splitting them to maintain valid DCEL topology. - let editing_edge_id = match &*shared.tool_state { - lightningbeam_core::tool::ToolState::EditingCurve { edge_id, .. } => Some(*edge_id), + // After editing vertices/curves/control points, rebuild CCW fan ordering + // at affected vertices and recompute edge intersections before snapshotting. + // Without this, stale fan ordering causes topology corruption on subsequent + // stroke insertions (e.g. face/cycle mismatches). + let editing_info = match &*shared.tool_state { + lightningbeam_core::tool::ToolState::EditingCurve { edge_id, .. } => { + Some((vec![*edge_id], vec![])) + } + lightningbeam_core::tool::ToolState::EditingVertex { vertex_id, connected_edges } => { + Some((connected_edges.clone(), vec![*vertex_id])) + } + lightningbeam_core::tool::ToolState::EditingControlPoint { edge_id, .. } => { + Some((vec![*edge_id], vec![])) + } _ => None, }; - if let Some(edge_id) = editing_edge_id { + if let Some((edge_ids, vertex_ids)) = editing_info { let document = shared.action_executor.document_mut(); if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) { if let Some(dcel) = vl.dcel_at_time_mut(cache.time) { - dcel.recompute_edge_intersections(edge_id); + // Rebuild fans at the directly edited vertices + for &vid in &vertex_ids { + dcel.rebuild_vertex_fan(vid); + } + // Also rebuild fans at endpoints of connected edges + // (their edge angles changed due to the edit) + for &eid in &edge_ids { + let [fwd, bwd] = dcel.edge(eid).half_edges; + let v1 = dcel.half_edge(fwd).origin; + let v2 = dcel.half_edge(bwd).origin; + if !vertex_ids.contains(&v1) { + dcel.rebuild_vertex_fan(v1); + } + if !vertex_ids.contains(&v2) { + dcel.rebuild_vertex_fan(v2); + } + } + // Repair face cycles at all affected vertices + // (rebuild_vertex_fan may have split cycles without updating faces) + let mut repaired: Vec = Vec::new(); + for &vid in &vertex_ids { + if !repaired.contains(&vid) { + dcel.repair_face_cycles_at_vertex(vid); + repaired.push(vid); + } + } + for &eid in &edge_ids { + let [fwd, bwd] = dcel.edge(eid).half_edges; + let v1 = dcel.half_edge(fwd).origin; + let v2 = dcel.half_edge(bwd).origin; + if !repaired.contains(&v1) { + dcel.repair_face_cycles_at_vertex(v1); + repaired.push(v1); + } + if !repaired.contains(&v2) { + dcel.repair_face_cycles_at_vertex(v2); + repaired.push(v2); + } + } + // Recompute intersections for all moved edges + for &eid in &edge_ids { + dcel.recompute_edge_intersections(eid); + } } } }