This commit is contained in:
Skyler Lehmkuhl 2026-03-01 03:03:57 -05:00
parent 9edfc2086a
commit 0026ad3e02
4 changed files with 647 additions and 18 deletions

View File

@ -49,6 +49,59 @@ impl Dcel {
area * 0.5 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. /// Get all half-edges on a face's outer boundary.
pub fn face_boundary(&self, face_id: FaceId) -> Vec<HalfEdgeId> { pub fn face_boundary(&self, face_id: FaceId) -> Vec<HalfEdgeId> {
let ohe = self.faces[face_id.idx()].outer_half_edge; let ohe = self.faces[face_id.idx()].outer_half_edge;

View File

@ -8,9 +8,9 @@
//! different intersection positions for the same crossing. //! different intersection positions for the same crossing.
use super::{ 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 crate::shape::{ShapeColor, StrokeStyle};
use kurbo::{CubicBez, ParamCurve, Point}; use kurbo::{CubicBez, ParamCurve, Point};
@ -375,6 +375,7 @@ impl Dcel {
// Splits inserted cv twice without maintaining the CCW fan — fix it // Splits inserted cv twice without maintaining the CCW fan — fix it
self.rebuild_vertex_fan(cv); self.rebuild_vertex_fan(cv);
self.repair_face_cycles_at_vertex(cv);
} }
// 2. Check against all other edges // 2. Check against all other edges
@ -543,7 +544,7 @@ mod tests {
// Actually for a simple chain (degree-2 vertices), there are exactly // 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 // 2 outgoing half-edges; pick the one that isn't the twin of how we arrived
if outgoing.len() == 2 { 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 // We want the outgoing that is NOT the reverse of our arrival
cur_he = if outgoing[0] == dcel.half_edges[twin.idx()].next { cur_he = if outgoing[0] == dcel.half_edges[twin.idx()].next {
// twin.next is the next outgoing in the fan — that's continuing back // 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 // Simpler approach: just verify that all 4 split vertices appear as
// endpoints of non-deleted edges, and that v1 and v2 are still endpoints. // endpoints of non-deleted edges, and that v1 and v2 are still endpoints.
let mut u_edge_vertices: Vec<VertexId> = Vec::new(); let mut u_edge_vertices: Vec<VertexId> = Vec::new();
for (i, edge) in dcel.edges.iter().enumerate() { for (_i, edge) in dcel.edges.iter().enumerate() {
if edge.deleted { continue; } if edge.deleted { continue; }
let [fwd, bwd] = edge.half_edges; let [fwd, bwd] = edge.half_edges;
let a = dcel.half_edges[fwd.idx()].origin; let a = dcel.half_edges[fwd.idx()].origin;
@ -958,4 +959,196 @@ mod tests {
dcel.validate(); 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();
}
} }

View File

@ -119,7 +119,7 @@ impl Dcel {
self.insert_edge_both_isolated(he_fwd, he_bwd, v1, v2, edge_id, face) self.insert_edge_both_isolated(he_fwd, he_bwd, v1, v2, edge_id, face)
} }
(false, false) => { (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) 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. /// 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( fn insert_edge_both_connected(
&mut self, &mut self,
he_fwd: HalfEdgeId, he_fwd: HalfEdgeId,
@ -214,6 +218,7 @@ impl Dcel {
v2: VertexId, v2: VertexId,
edge_id: EdgeId, edge_id: EdgeId,
curve: &CubicBez, curve: &CubicBez,
face_hint: FaceId,
) -> (EdgeId, FaceId) { ) -> (EdgeId, FaceId) {
let fwd_angle = Self::curve_start_angle(curve); let fwd_angle = Self::curve_start_angle(curve);
let bwd_angle = Self::curve_end_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_v1 = self.half_edges[into_v1.idx()].face;
let face_v2 = self.half_edges[into_v2.idx()].face; let face_v2 = self.half_edges[into_v2.idx()].face;
debug_assert_eq!(
face_v1, face_v2, // If the angular ordering places both predecessors in F0 but the
"insert_edge_both_connected: into_v1 (HE{}) on {:?} but into_v2 (HE{}) on {:?}", // caller expects a non-F0 face, try the opposite sector: use
into_v1.0, face_v1, into_v2.0, face_v2 // `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) =
let actual_face = face_v1; 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: // Splice:
// into_v1 → he_fwd → ccw_v2 → ... // into_v1 → he_fwd → ccw_v2 → ...
@ -295,6 +319,49 @@ impl Dcel {
(edge_id, new_face) (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`. /// Check if walking the cycle from `start` encounters `target`.
fn cycle_contains(&self, start: HalfEdgeId, target: HalfEdgeId) -> bool { fn cycle_contains(&self, start: HalfEdgeId, target: HalfEdgeId) -> bool {
let mut cur = self.half_edges[start.idx()].next; let mut cur = self.half_edges[start.idx()].next;
@ -594,6 +661,200 @@ impl Dcel {
self.vertices[vertex.idx()].outgoing = fan[0].1; 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<FaceId> {
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<HalfEdgeId> = Vec::new();
let mut seen_cycle_reps: Vec<HalfEdgeId> = 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<VertexId, HalfEdgeId> = 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<HalfEdgeId> = 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<FaceId, Vec<HalfEdgeId>> = 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 /// 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 /// (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. /// 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.vertices[v2.idx()].outgoing.is_none());
assert!(dcel.edges[edge_id.idx()].deleted); 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();
}
} }

View File

@ -2969,19 +2969,70 @@ impl StagePane {
} }
}; };
// If we were editing a curve, recompute intersections before snapshotting. // After editing vertices/curves/control points, rebuild CCW fan ordering
// This detects new crossings between the edited edge and other edges, // at affected vertices and recompute edge intersections before snapshotting.
// splitting them to maintain valid DCEL topology. // Without this, stale fan ordering causes topology corruption on subsequent
let editing_edge_id = match &*shared.tool_state { // stroke insertions (e.g. face/cycle mismatches).
lightningbeam_core::tool::ToolState::EditingCurve { edge_id, .. } => Some(*edge_id), 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, _ => 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(); let document = shared.action_executor.document_mut();
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) { if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) {
if let Some(dcel) = vl.dcel_at_time_mut(cache.time) { 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<lightningbeam_core::dcel2::VertexId> = 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);
}
} }
} }
} }