1155 lines
44 KiB
Rust
1155 lines
44 KiB
Rust
//! 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,
|
||
};
|
||
use crate::curve_intersections::find_curve_intersections;
|
||
use crate::shape::{ShapeColor, StrokeStyle};
|
||
use kurbo::{CubicBez, ParamCurve, Point};
|
||
|
||
pub struct InsertStrokeResult {
|
||
pub new_vertices: Vec<VertexId>,
|
||
pub new_edges: Vec<EdgeId>,
|
||
pub split_edges: Vec<(EdgeId, f64, VertexId, EdgeId)>,
|
||
pub new_faces: Vec<FaceId>,
|
||
}
|
||
|
||
/// 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<SegmentSplit> {
|
||
let mut splits: Vec<SegmentSplit> = 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<StrokeStyle>,
|
||
stroke_color: Option<ShapeColor>,
|
||
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<CubicBez> = 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<VertexId> = 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);
|
||
self.repair_face_cycles_at_vertex(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<VertexId> = 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<VertexId> = 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<VertexId> = 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<EdgeId> = 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<f64> = 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<VertexId> = 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<EdgeId> = 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();
|
||
}
|
||
|
||
/// 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();
|
||
|
||
// 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();
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
}
|