From 273939125753762c4dc07492d08e379152579604 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 24 Feb 2026 11:12:17 -0500 Subject: [PATCH] the pain of geometry programming --- .../src/curve_intersections.rs | 88 ++- .../lightningbeam-core/src/dcel.rs | 499 ++++++++++++++++-- 2 files changed, 538 insertions(+), 49 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs b/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs index 6f721ac..36bcd51 100644 --- a/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs +++ b/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs @@ -74,8 +74,9 @@ fn find_intersections_recursive( // Maximum recursion depth const MAX_DEPTH: usize = 20; - // Minimum parameter range (if smaller, we've found an intersection) - const MIN_RANGE: f64 = 0.001; + // Pixel-space convergence threshold: stop subdividing when both + // subsegments span less than this many pixels. + const PIXEL_TOL: f64 = 0.25; // Get bounding boxes of current subsegments let bbox1 = curve1.bounding_box(); @@ -90,25 +91,64 @@ fn find_intersections_recursive( return; } - // If we've recursed deep enough or ranges are small enough, - // refine with line-line intersection for sub-pixel accuracy. - if depth >= MAX_DEPTH || - ((t1_end - t1_start) < MIN_RANGE && (t2_end - t2_start) < MIN_RANGE) { - // At this scale the curves are essentially straight lines. - // Evaluate endpoints of each subsegment and solve line-line. - let a0 = orig_curve1.eval(t1_start); - let a1 = orig_curve1.eval(t1_end); - let b0 = orig_curve2.eval(t2_start); - let b1 = orig_curve2.eval(t2_end); + // Evaluate subsegment endpoints for convergence check and line-line solve + let a0 = orig_curve1.eval(t1_start); + let a1 = orig_curve1.eval(t1_end); + let b0 = orig_curve2.eval(t2_start); + let b1 = orig_curve2.eval(t2_end); + + // Check convergence in pixel space: both subsegment spans must be + // below the tolerance. This ensures the linear approximation error + // is always well within the vertex snap threshold regardless of + // curve length. + let a_span = (a1 - a0).hypot(); + let b_span = (b1 - b0).hypot(); + + if depth >= MAX_DEPTH || (a_span < PIXEL_TOL && b_span < PIXEL_TOL) { let (t1, t2, point) = if let Some((s, u)) = line_line_intersect(a0, a1, b0, b1) { let s = s.clamp(0.0, 1.0); let u = u.clamp(0.0, 1.0); - let t1 = t1_start + s * (t1_end - t1_start); - let t2 = t2_start + u * (t2_end - t2_start); - // Average the two lines' estimates for the point - let p1 = Point::new(a0.x + s * (a1.x - a0.x), a0.y + s * (a1.y - a0.y)); - let p2 = Point::new(b0.x + u * (b1.x - b0.x), b0.y + u * (b1.y - b0.y)); + let mut t1 = t1_start + s * (t1_end - t1_start); + let mut t2 = t2_start + u * (t2_end - t2_start); + + // Newton refinement: converge t1, t2 so that + // curve1.eval(t1) == curve2.eval(t2) to sub-pixel accuracy. + // We solve F(t1,t2) = curve1(t1) - curve2(t2) = 0 via the + // Jacobian [d1, -d2] where d1/d2 are the curve tangents. + let t1_orig = t1; + let t2_orig = t2; + for _ in 0..8 { + let p1 = orig_curve1.eval(t1); + let p2 = orig_curve2.eval(t2); + let err = Point::new(p1.x - p2.x, p1.y - p2.y); + if err.x * err.x + err.y * err.y < 1e-6 { + break; + } + // Tangent vectors (derivative of cubic bezier) + let d1 = cubic_deriv(orig_curve1, t1); + let d2 = cubic_deriv(orig_curve2, t2); + // Solve [d1.x, -d2.x; d1.y, -d2.y] * [dt1; dt2] = -[err.x; err.y] + let det = d1.x * (-d2.y) - d1.y * (-d2.x); + if det.abs() < 1e-12 { + break; // tangents parallel, can't refine + } + let dt1 = (-d2.y * (-err.x) - (-d2.x) * (-err.y)) / det; + let dt2 = (d1.x * (-err.y) - d1.y * (-err.x)) / det; + t1 = (t1 + dt1).clamp(0.0, 1.0); + t2 = (t2 + dt2).clamp(0.0, 1.0); + } + // If Newton diverged far from the initial estimate, it may have + // jumped to a different crossing. Reject and fall back. + if (t1 - t1_orig).abs() > (t1_end - t1_start) * 2.0 + || (t2 - t2_orig).abs() > (t2_end - t2_start) * 2.0 + { + t1 = t1_orig; + t2 = t2_orig; + } + + let p1 = orig_curve1.eval(t1); + let p2 = orig_curve2.eval(t2); (t1, t2, Point::new((p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5)) } else { // Lines are parallel/degenerate — fall back to midpoint @@ -329,6 +369,20 @@ fn dedup_intersections(intersections: &mut Vec, _tolerance: f64) { *intersections = result; } +/// Derivative (tangent vector) of a cubic Bezier at parameter t. +/// +/// B'(t) = 3[(1-t)²(P1-P0) + 2(1-t)t(P2-P1) + t²(P3-P2)] +fn cubic_deriv(c: &CubicBez, t: f64) -> Point { + let u = 1.0 - t; + let d0 = Point::new(c.p1.x - c.p0.x, c.p1.y - c.p0.y); + let d1 = Point::new(c.p2.x - c.p1.x, c.p2.y - c.p1.y); + let d2 = Point::new(c.p3.x - c.p2.x, c.p3.y - c.p2.y); + Point::new( + 3.0 * (u * u * d0.x + 2.0 * u * t * d1.x + t * t * d2.x), + 3.0 * (u * u * d0.y + 2.0 * u * t * d1.y + t * t * d2.y), + ) +} + /// 2D line-line intersection. /// /// Given line segment A (a0→a1) and line segment B (b0→b1), diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel.rs b/lightningbeam-ui/lightningbeam-core/src/dcel.rs index 02994b8..e19e830 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel.rs @@ -927,11 +927,35 @@ impl Dcel { ); } - // 6. No unsplit crossings: every pair of non-deleted edges that + // 6. Curve endpoints match vertex positions: for every edge, + // curve.p0 must equal the origin of half_edges[0] and + // curve.p3 must equal the origin of half_edges[1]. + for (i, e) in self.edges.iter().enumerate() { + if e.deleted { continue; } + let e_id = EdgeId(i as u32); + let v0 = self.half_edges[e.half_edges[0].idx()].origin; + let v1 = self.half_edges[e.half_edges[1].idx()].origin; + let p0 = self.vertices[v0.idx()].position; + let p3 = self.vertices[v1.idx()].position; + let d0 = (e.curve.p0 - p0).hypot(); + let d3 = (e.curve.p3 - p3).hypot(); + assert!( + d0 < 0.01, + "Edge {:?} curve.p0 ({:.2},{:.2}) doesn't match V{} ({:.2},{:.2}), dist={:.2}", + e_id, e.curve.p0.x, e.curve.p0.y, v0.0, p0.x, p0.y, d0 + ); + assert!( + d3 < 0.01, + "Edge {:?} curve.p3 ({:.2},{:.2}) doesn't match V{} ({:.2},{:.2}), dist={:.2}", + e_id, e.curve.p3.x, e.curve.p3.y, v1.0, p3.x, p3.y, d3 + ); + } + + // 7. No unsplit crossings: every pair of non-deleted edges that // geometrically cross must share a vertex at the crossing point. // An interior crossing (away from endpoints) without a shared // vertex means insert_stroke failed to split the edge. - { + if cfg!(debug_assertions) { use crate::curve_intersections::find_curve_intersections; // Collect live edges with their endpoint vertex IDs. @@ -1220,24 +1244,21 @@ impl Dcel { // new face. We detect this by computing the signed area of each // cycle via the bezpath: positive area = CCW interior, negative // or larger absolute = CW exterior. - let (he_old, he_new) = if actual_face.0 == 0 { - // Compute signed area of both cycles to determine which is - // the exterior. The exterior has larger absolute area. - let fwd_cycle = self.walk_cycle(he_fwd); - let bwd_cycle = self.walk_cycle(he_bwd); - let fwd_path = self.cycle_to_bezpath(&fwd_cycle); - let bwd_path = self.cycle_to_bezpath(&bwd_cycle); - let fwd_area = kurbo::Shape::area(&fwd_path); - let bwd_area = kurbo::Shape::area(&bwd_path); - if fwd_area.abs() < bwd_area.abs() { - // he_fwd is the smaller (interior) → he_fwd gets new_face - (he_bwd, he_fwd) - } else { - // he_fwd is the larger (exterior) → he_bwd gets new_face - (he_fwd, he_bwd) - } + // Compute signed area of both cycles to determine which + // keeps the old face. The larger cycle (by absolute area) + // retains actual_face; the smaller one gets new_face. + // This is essential for both the unbounded face (where the + // exterior must stay as face 0) and bounded faces (where + // the wrong assignment causes bloated face cycles). + let fwd_cycle = self.walk_cycle(he_fwd); + let bwd_cycle = self.walk_cycle(he_bwd); + let fwd_path = self.cycle_to_bezpath(&fwd_cycle); + let bwd_path = self.cycle_to_bezpath(&bwd_cycle); + let fwd_area = kurbo::Shape::area(&fwd_path); + let bwd_area = kurbo::Shape::area(&bwd_path); + let (he_old, he_new) = if fwd_area.abs() < bwd_area.abs() { + (he_bwd, he_fwd) } else { - // For bounded faces, convention: he_fwd → old, he_bwd → new (he_fwd, he_bwd) }; @@ -1406,13 +1427,22 @@ impl Dcel { let original_curve = self.edges[edge_id.idx()].curve; // De Casteljau subdivision - let (curve_a, curve_b) = subdivide_cubic(original_curve, t); + let (mut curve_a, mut curve_b) = subdivide_cubic(original_curve, t); let split_point = curve_a.p3; // == curve_b.p0 let new_vertex = self .snap_vertex(split_point, DEFAULT_SNAP_EPSILON) .unwrap_or_else(|| self.alloc_vertex(split_point)); + // If the vertex was snapped to a different position, adjust curve + // endpoints so they exactly match the vertex. Without this, the + // SVG curves and the vertex circles drift apart and different curve + // pairs that cross at the same visual point produce vertices that + // never merge. + let vpos = self.vertices[new_vertex.idx()].position; + curve_a.p3 = vpos; + curve_b.p0 = vpos; + // Get the original half-edges let [he_fwd, he_bwd] = self.edges[edge_id.idx()].half_edges; @@ -1971,7 +2001,13 @@ impl Dcel { continue; } - let sub_curve = subsegment_cubic(*seg, prev_t, *t); + let mut sub_curve = subsegment_cubic(*seg, prev_t, *t); + + // Adjust curve endpoints to exactly match vertex positions. + // Vertices may have been snapped to a nearby existing vertex, + // so the curve from subsegment_cubic can be a few pixels off. + sub_curve.p0 = self.vertices[prev_vertex.idx()].position; + sub_curve.p3 = self.vertices[vertex.idx()].position; // Find the face containing this edge's midpoint for insertion let mid = midpoint_of_cubic(&sub_curve); @@ -2007,12 +2043,205 @@ impl Dcel { stroke_vertices.push(end_v); } + // Post-insertion repair: check newly inserted stroke edges against ALL + // other edges for crossings that the pre-insertion detection missed. + // This can happen when an existing edge is split during insertion, + // creating a new upper-portion edge (index >= existing_edge_count) + // that was never checked against later stroke segments. + self.repair_unsplit_crossings(&mut result); + #[cfg(debug_assertions)] self.validate(); result } + // ----------------------------------------------------------------------- + // repair_unsplit_crossings: post-insertion fix for missed intersections + // ----------------------------------------------------------------------- + + /// After inserting stroke edges, check each new edge against every other + /// edge for interior crossings that lack a shared vertex. Split both + /// edges at each crossing and merge the resulting co-located vertices. + /// + /// This catches crossings missed by the pre-insertion detection, which + /// only checks segments against `0..existing_edge_count` and therefore + /// misses edges created by `split_edge` during the insertion process. + fn repair_unsplit_crossings(&mut self, result: &mut InsertStrokeResult) { + use crate::curve_intersections::find_curve_intersections; + + // We need to check every new edge against every other edge (both + // new and pre-existing). Collect new edge IDs into a set for + // fast membership lookup. + let new_edge_set: std::collections::HashSet = result + .new_edges + .iter() + .map(|e| e.0) + .collect(); + + // For each new edge, check against all other edges. + // We iterate by index because self is borrowed mutably during fixes. + let mut crossing_pairs: Vec<(EdgeId, f64, EdgeId, f64, Point)> = Vec::new(); + + // Snapshot: collect edge data so we don't borrow self during iteration. + let edge_infos: Vec<(EdgeId, CubicBez, [VertexId; 2], bool)> = self + .edges + .iter() + .enumerate() + .map(|(i, e)| { + let eid = EdgeId(i as u32); + if e.deleted { + return (eid, CubicBez::new((0., 0.), (0., 0.), (0., 0.), (0., 0.)), [VertexId::NONE; 2], true); + } + let v0 = self.half_edges[e.half_edges[0].idx()].origin; + let v1 = self.half_edges[e.half_edges[1].idx()].origin; + (eid, e.curve, [v0, v1], false) + }) + .collect(); + + for &new_eid in &result.new_edges { + let (_, curve_a, verts_a, del_a) = &edge_infos[new_eid.idx()]; + if *del_a { + continue; + } + + for (eid_b, curve_b, verts_b, del_b) in &edge_infos { + if *del_b || *eid_b == new_eid { + continue; + } + // Only check each pair once: if both are new edges, only + // check when new_eid < eid_b. + if new_edge_set.contains(&eid_b.0) && new_eid.0 >= eid_b.0 { + continue; + } + + // Shared endpoint vertices + let shared: Vec = verts_a + .iter() + .filter(|v| verts_b.contains(v)) + .copied() + .collect(); + + let hits = find_curve_intersections(curve_a, curve_b); + for hit in &hits { + let t1 = hit.t1; + let t2 = hit.t2.unwrap_or(0.5); + + // Skip near-shared-vertex hits + let close_to_shared = shared.iter().any(|&sv| { + if sv.is_none() { return false; } + let sv_pos = self.vertex(sv).position; + (hit.point - sv_pos).hypot() < 2.0 + }); + if close_to_shared { + continue; + } + + // Skip near-endpoint on both + if (t1 < 0.02 || t1 > 0.98) && (t2 < 0.02 || t2 > 0.98) { + continue; + } + + // Check if a vertex already exists at this crossing + let has_vertex = self.vertices.iter().any(|v| { + !v.deleted && (v.position - hit.point).hypot() < 2.0 + }); + if has_vertex { + continue; + } + + crossing_pairs.push((new_eid, t1, *eid_b, t2, hit.point)); + } + } + } + + if crossing_pairs.is_empty() { + return; + } + + // Deduplicate near-identical crossings (same edge pair, close points) + crossing_pairs.sort_by(|a, b| { + a.0 .0.cmp(&b.0 .0) + .then(a.2 .0.cmp(&b.2 .0)) + .then(a.1.partial_cmp(&b.1).unwrap()) + }); + crossing_pairs.dedup_by(|a, b| { + a.0 == b.0 && a.2 == b.2 && (a.4 - b.4).hypot() < 2.0 + }); + + // Group crossings by edge so we can split from high-t to low-t. + // For each crossing, split both edges and record vertex pairs to merge. + let mut merge_pairs: Vec<(VertexId, VertexId)> = Vec::new(); + + // Process one crossing at a time since splits change edge geometry. + // After each split, the remaining crossings' t-values may be stale, + // so we re-detect. In practice there are very few missed crossings. + for (eid_a, t_a, eid_b, t_b, _point) in &crossing_pairs { + // Edges may have been deleted/split by a prior iteration + if self.edges[eid_a.idx()].deleted || self.edges[eid_b.idx()].deleted { + continue; + } + + // Re-verify the crossing still exists on these exact edges + let curve_a = self.edges[eid_a.idx()].curve; + let curve_b = self.edges[eid_b.idx()].curve; + let hits = find_curve_intersections(&curve_a, &curve_b); + + // Find the hit closest to the original (t_a, t_b) + let mut best: Option<(f64, f64)> = None; + for hit in &hits { + let ht1 = hit.t1; + let ht2 = hit.t2.unwrap_or(0.5); + // Must be interior on both edges + if ht1 < 0.01 || ht1 > 0.99 || ht2 < 0.01 || ht2 > 0.99 { + continue; + } + // Check it's near the expected point + let has_vertex = self.vertices.iter().any(|v| { + !v.deleted && (v.position - hit.point).hypot() < 2.0 + }); + if has_vertex { + continue; + } + if best.is_none() + || (ht1 - t_a).abs() + (ht2 - t_b).abs() + < (best.unwrap().0 - t_a).abs() + (best.unwrap().1 - t_b).abs() + { + best = Some((ht1, ht2)); + } + } + + let Some((split_t_a, split_t_b)) = best else { + continue; + }; + + // Split both edges + let (v_a, new_edge_a) = self.split_edge(*eid_a, split_t_a); + result.split_edges.push((*eid_a, split_t_a, v_a, new_edge_a)); + + let (v_b, new_edge_b) = self.split_edge(*eid_b, split_t_b); + result.split_edges.push((*eid_b, split_t_b, v_b, new_edge_b)); + + // If snap_vertex already merged them, no need to merge again + if v_a != v_b { + merge_pairs.push((v_a, v_b)); + } + } + + // Merge co-located vertex pairs + let has_merges = !merge_pairs.is_empty(); + for (va, vb) in &merge_pairs { + if self.vertices[va.idx()].deleted || self.vertices[vb.idx()].deleted { + continue; + } + self.merge_vertices_at_crossing(*va, *vb); + } + + if has_merges { + self.reassign_faces_after_merges(); + } + } + // ----------------------------------------------------------------------- // recompute_edge_intersections: find and split new intersections after edit // ----------------------------------------------------------------------- @@ -2138,18 +2367,41 @@ impl Dcel { let t1_tol = spatial_tol / edited_len; let t2_tol = spatial_tol / other_len; + // Get endpoint vertices for shared-vertex check + let edited_v0 = self.half_edges[self.edges[edge_id.idx()].half_edges[0].idx()].origin; + let edited_v1 = self.half_edges[self.edges[edge_id.idx()].half_edges[1].idx()].origin; + let other_v0 = self.half_edges[e.half_edges[0].idx()].origin; + let other_v1 = self.half_edges[e.half_edges[1].idx()].origin; + let shared: Vec = [edited_v0, edited_v1] + .iter() + .filter(|v| *v == &other_v0 || *v == &other_v1) + .copied() + .collect(); + let intersections = find_curve_intersections(&edited_curve, &e.curve); for inter in intersections { if let Some(t2) = inter.t2 { - // Skip intersections where either t is too close to an - // endpoint to produce a usable split. The threshold is - // scaled by arc length so it corresponds to a consistent - // spatial tolerance. This filters: - // - Shared-vertex hits (both t near endpoints) - // - Spurious near-vertex bbox-overlap false positives - // - Hits that would create one-sided splits - if inter.t1 < t1_tol || inter.t1 > 1.0 - t1_tol - || t2 < t2_tol || t2 > 1.0 - t2_tol + // Skip intersections near a shared endpoint vertex + let close_to_shared = shared.iter().any(|&sv| { + let sv_pos = self.vertex(sv).position; + (inter.point - sv_pos).hypot() < 2.0 + }); + if close_to_shared { + continue; + } + + // Skip intersections near endpoints on BOTH edges + // (shared vertex or coincident endpoints). + let near_endpoint_a = inter.t1 < t1_tol || inter.t1 > 1.0 - t1_tol; + let near_endpoint_b = t2 < t2_tol || t2 > 1.0 - t2_tol; + if near_endpoint_a && near_endpoint_b { + continue; + } + + // Skip if too close to an endpoint to produce a usable + // split, but only with a tight spatial threshold. + if (inter.t1 < 0.001 || inter.t1 > 0.999) + && (t2 < 0.001 || t2 > 0.999) { continue; } @@ -2697,7 +2949,39 @@ mod tests { if face.fill_color.is_none() { continue; } if face.outer_half_edge.is_none() { continue; } - let bez = dcel.face_to_bezpath_stripped(FaceId(i as u32)); + let fid = FaceId(i as u32); + let mut bez = dcel.face_to_bezpath_stripped(fid); + + // Subtract any other face that is geometrically inside this face + // but topologically disconnected (no shared edges). These are + // concentric/nested cycles that should appear as holes. + let outer_path = dcel.face_to_bezpath_stripped(fid); + let outer_cycle = dcel.face_boundary(fid); + let outer_edges: std::collections::HashSet = outer_cycle + .iter() + .map(|&he| dcel.half_edge(he).edge) + .collect(); + for (j, other) in dcel.faces.iter().enumerate() { + if j == i || j == 0 || other.deleted || other.outer_half_edge.is_none() { + continue; + } + let other_cycle = dcel.face_boundary(FaceId(j as u32)); + if other_cycle.is_empty() { continue; } + // Skip if the two faces share any edge (they're adjacent, not nested) + let shares_edge = other_cycle.iter().any(|&he| { + outer_edges.contains(&dcel.half_edge(he).edge) + }); + if shares_edge { continue; } + // Check if a point on the other face's boundary is inside this face + let sample_he = other_cycle[0]; + let sample_pt = dcel.edge(dcel.half_edge(sample_he).edge).curve.eval(0.5); + if kurbo::Shape::winding(&outer_path, sample_pt) != 0 { + let hole = dcel.face_to_bezpath_stripped(FaceId(j as u32)); + for el in hole.elements() { + bez.push(*el); + } + } + } // Convert kurbo BezPath to tiny-skia PathBuilder let mut pb = tiny_skia::PathBuilder::new(); @@ -2730,7 +3014,7 @@ mod tests { pixmap.fill_path( &path, &paint, - tiny_skia::FillRule::Winding, + tiny_skia::FillRule::EvenOdd, tiny_skia::Transform::identity(), None, ); @@ -3599,6 +3883,34 @@ mod tests { assert_paint_sequence(&mut dcel, &paint_points, 800, 450); } + #[test] + fn test_concentric_ellipses() { + let mut dcel = Dcel::new(); + + // Stroke 0 — inner ellipse + dcel.insert_stroke(&[ + CubicBez::new(Point::new(547.7, 237.8), Point::new(547.7, 218.3), Point::new(518.1, 202.6), Point::new(481.4, 202.6)), + CubicBez::new(Point::new(481.4, 202.6), Point::new(444.8, 202.6), Point::new(415.1, 218.3), Point::new(415.1, 237.8)), + CubicBez::new(Point::new(415.1, 237.8), Point::new(415.1, 257.2), Point::new(444.8, 272.9), Point::new(481.4, 272.9)), + CubicBez::new(Point::new(481.4, 272.9), Point::new(518.1, 272.9), Point::new(547.7, 257.2), Point::new(547.7, 237.8)), + ], None, None, 5.0); + + // Stroke 1 — outer ellipse + dcel.insert_stroke(&[ + CubicBez::new(Point::new(693.6, 255.9), Point::new(693.6, 197.6), Point::new(609.8, 150.3), Point::new(506.5, 150.3)), + CubicBez::new(Point::new(506.5, 150.3), Point::new(403.2, 150.3), Point::new(319.5, 197.6), Point::new(319.5, 255.9)), + CubicBez::new(Point::new(319.5, 255.9), Point::new(319.5, 314.2), Point::new(403.2, 361.5), Point::new(506.5, 361.5)), + CubicBez::new(Point::new(506.5, 361.5), Point::new(609.8, 361.5), Point::new(693.6, 314.2), Point::new(693.6, 255.9)), + ], None, None, 5.0); + + // Test both orderings — outer first should also work + let paint_points = vec![ + Point::new(400.5, 319.5), + Point::new(497.0, 251.4), + ]; + assert_paint_sequence(&mut dcel, &paint_points, 800, 450); + } + #[test] fn test_recorded_eight_strokes() { let mut dcel = Dcel::new(); @@ -3716,6 +4028,129 @@ mod tests { assert_paint_sequence(&mut dcel, &paint_points, 600, 400); } + #[test] + fn test_recorded_six_strokes_four_fills() { + let mut dcel = Dcel::new(); + + // Stroke 0 + dcel.insert_stroke(&[ + CubicBez::new(Point::new(279.5, 405.9), Point::new(342.3, 330.5), Point::new(404.0, 254.0), Point::new(478.1, 188.9)), + CubicBez::new(Point::new(478.1, 188.9), Point::new(505.1, 165.2), Point::new(539.1, 148.1), Point::new(564.2, 123.0)), + ], None, None, 5.0); + + // Stroke 1 + dcel.insert_stroke(&[ + CubicBez::new(Point::new(281.5, 209.9), Point::new(414.0, 241.1), Point::new(556.8, 218.5), Point::new(684.7, 269.7)), + ], None, None, 5.0); + + // Stroke 2 + dcel.insert_stroke(&[ + CubicBez::new(Point::new(465.3, 334.9), Point::new(410.9, 307.7), Point::new(370.5, 264.5), Point::new(343.4, 210.4)), + CubicBez::new(Point::new(343.4, 210.4), Point::new(337.5, 198.6), Point::new(321.9, 120.9), Point::new(303.9, 120.9)), + ], None, None, 5.0); + + // Stroke 3 + dcel.insert_stroke(&[ + CubicBez::new(Point::new(244.0, 290.7), Point::new(281.2, 279.8), Point::new(474.1, 242.2), Point::new(511.9, 237.8)), + CubicBez::new(Point::new(511.9, 237.8), Point::new(540.4, 234.5), Point::new(569.7, 236.9), Point::new(598.0, 231.7)), + CubicBez::new(Point::new(598.0, 231.7), Point::new(620.5, 227.5), Point::new(699.3, 190.4), Point::new(703.4, 190.4)), + ], None, None, 5.0); + + // Stroke 4 + dcel.insert_stroke(&[ + CubicBez::new(Point::new(303.2, 146.2), Point::new(442.0, 146.2), Point::new(598.7, 124.5), Point::new(674.9, 269.2)), + CubicBez::new(Point::new(674.9, 269.2), Point::new(684.7, 287.9), Point::new(699.5, 302.6), Point::new(699.5, 324.2)), + ], None, None, 5.0); + + // Stroke 5 + dcel.insert_stroke(&[ + CubicBez::new(Point::new(409.7, 328.3), Point::new(389.8, 248.7), Point::new(409.7, 161.3), Point::new(409.7, 80.6)), + ], None, None, 5.0); + + let paint_points = vec![ + Point::new(403.0, 257.7), + Point::new(392.0, 263.6), + Point::new(381.1, 235.2), + Point::new(357.0, 167.1), + ]; + + // Dump all vertices + eprintln!("=== All vertices ==="); + for (i, v) in dcel.vertices.iter().enumerate() { + if v.deleted { continue; } + eprintln!(" V{i} ({:.1},{:.1}) outgoing=HE{}", v.position.x, v.position.y, v.outgoing.0); + } + + // Debug: show what faces each point would hit and their areas + for (i, &pt) in paint_points.iter().enumerate() { + use kurbo::Shape as _; + let face = dcel.find_face_containing_point(pt); + if face.0 != 0 { + let cycle = dcel.face_boundary(face); + let stripped = dcel.strip_cycle(&cycle); + let path = dcel.face_to_bezpath_stripped(face); + let area = path.area().abs(); + eprintln!(" point {i} ({:.1},{:.1}) → F{} cycle_len={} stripped_len={} area={:.1}", + pt.x, pt.y, face.0, cycle.len(), stripped.len(), area); + // Show stripped cycle vertices + for (j, &he_id) in stripped.iter().enumerate() { + let src = dcel.half_edge_source(he_id); + let pos = dcel.vertex(src).position; + eprintln!(" [{j}] HE{} V{} ({:.1},{:.1})", he_id.0, src.0, pos.x, pos.y); + } + } else { + eprintln!(" point {i} ({:.1},{:.1}) → UNBOUNDED", pt.x, pt.y); + } + } + + // Dump SVG for debugging + { + let mut svg = String::new(); + svg.push_str("\n"); + svg.push_str("\n"); + let colors = ["#e6194b","#3cb44b","#4363d8","#f58231","#911eb4", + "#42d4f4","#f032e6","#bfef45","#fabed4","#469990", + "#dcbeff","#9A6324","#800000","#aaffc3","#808000", + "#ffd8b1","#000075","#808080","#000000","#ffe119"]; + for (i, e) in dcel.edges.iter().enumerate() { + if e.deleted { continue; } + let c = &e.curve; + let color = colors[i % colors.len()]; + let v0 = dcel.half_edges[e.half_edges[0].idx()].origin; + let v1 = dcel.half_edges[e.half_edges[1].idx()].origin; + svg.push_str(&format!( + "\n", + c.p0.x, c.p0.y, c.p1.x, c.p1.y, c.p2.x, c.p2.y, c.p3.x, c.p3.y, color + )); + let mid = c.eval(0.5); + svg.push_str(&format!( + "E{}(V{}→V{})\n", + mid.x, mid.y - 2.0, color, i, v0.0, v1.0 + )); + } + for (i, v) in dcel.vertices.iter().enumerate() { + if v.deleted { continue; } + svg.push_str(&format!( + "\n\ + V{}\n", + v.position.x, v.position.y, v.position.x + 3.0, v.position.y - 3.0, i + )); + } + for (i, &pt) in paint_points.iter().enumerate() { + svg.push_str(&format!( + "\n\ + P{}\n", + pt.x, pt.y, pt.x + 5.0, pt.y - 5.0, i + )); + } + svg.push_str("\n"); + std::fs::write("/tmp/dcel_six_strokes.svg", &svg).unwrap(); + eprintln!("SVG written to /tmp/dcel_six_strokes.svg"); + } + + assert_paint_sequence(&mut dcel, &paint_points, 750, 450); + } + #[test] fn test_dump_svg() { let mut dcel = Dcel::new();