fix dcel
This commit is contained in:
parent
9edfc2086a
commit
0026ad3e02
|
|
@ -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<HalfEdgeId> {
|
||||
let ohe = self.faces[face_id.idx()].outer_half_edge;
|
||||
|
|
|
|||
|
|
@ -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<VertexId> = 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
/// (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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue