Compare commits

...

2 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 8acac71d86 Merge branch 'rust-ui' of https://git.skyler.io/skyler/Lightningbeam into rust-ui 2026-03-13 18:53:37 -04:00
Skyler Lehmkuhl c9a9c2c5f0 rewrite vector backend again 2026-03-13 18:53:33 -04:00
9 changed files with 3077 additions and 3 deletions

View File

@ -139,12 +139,25 @@ fn find_intersections_recursive(
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.
// jumped to a different crossing. Check if the refined result is
// actually better than the original before rejecting.
let p1_refined = orig_curve1.eval(t1);
let p2_refined = orig_curve2.eval(t2);
let err_refined = (p1_refined.x - p2_refined.x).powi(2)
+ (p1_refined.y - p2_refined.y).powi(2);
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 = orig_curve1.eval(t1_orig);
let p2_orig = orig_curve2.eval(t2_orig);
let err_orig = (p1_orig.x - p2_orig.x).powi(2)
+ (p1_orig.y - p2_orig.y).powi(2);
// Only fall back if the original is actually closer
if err_orig < err_refined {
t1 = t1_orig;
t2 = t2_orig;
}
}
let p1 = orig_curve1.eval(t1);

View File

@ -47,6 +47,7 @@ pub(crate) mod clipboard_platform;
pub mod region_select;
pub mod dcel2;
pub use dcel2 as dcel;
pub mod vector_graph;
pub mod svg_export;
pub mod snap;
pub mod webcam;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,253 @@
//! Basic graph construction, vertex/edge/fill CRUD, adjacency, BezPath generation.
use super::super::*;
use kurbo::{CubicBez, Point};
/// Helper: create a straight-line cubic Bézier from a to b.
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
// ── Vertex CRUD ──────────────────────────────────────────────────────────
#[test]
fn alloc_vertex() {
let mut g = VectorGraph::new();
let v = g.alloc_vertex(Point::new(10.0, 20.0));
assert_eq!(g.vertex(v).position, Point::new(10.0, 20.0));
assert!(!g.vertex(v).deleted);
}
#[test]
fn free_and_reuse_vertex() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(1.0, 2.0));
g.free_vertex(v0);
assert!(g.vertex(v0).deleted);
// Next alloc should reuse the freed slot
let v1 = g.alloc_vertex(Point::new(3.0, 4.0));
assert_eq!(v0, v1);
assert_eq!(g.vertex(v1).position, Point::new(3.0, 4.0));
assert!(!g.vertex(v1).deleted);
}
// ── Edge CRUD ────────────────────────────────────────────────────────────
#[test]
fn alloc_edge_with_stroke() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(0.0, 0.0));
let v1 = g.alloc_vertex(Point::new(100.0, 0.0));
let style = StrokeStyle { width: 2.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let e = g.alloc_edge(line(Point::ZERO, Point::new(100.0, 0.0)), v0, v1, Some(style), Some(color));
assert_eq!(g.edge(e).vertices, [v0, v1]);
assert!(g.edge_is_visible(e));
}
#[test]
fn alloc_invisible_edge() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(0.0, 0.0));
let v1 = g.alloc_vertex(Point::new(50.0, 0.0));
let e = g.alloc_edge(line(Point::ZERO, Point::new(50.0, 0.0)), v0, v1, None, None);
assert!(!g.edge_is_visible(e));
}
// ── Fill CRUD ────────────────────────────────────────────────────────────
#[test]
fn alloc_fill_with_boundary() {
let mut g = VectorGraph::new();
// Build a triangle: 3 vertices, 3 edges
let p0 = Point::new(0.0, 0.0);
let p1 = Point::new(100.0, 0.0);
let p2 = Point::new(50.0, 100.0);
let v0 = g.alloc_vertex(p0);
let v1 = g.alloc_vertex(p1);
let v2 = g.alloc_vertex(p2);
let style = StrokeStyle { width: 1.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let e0 = g.alloc_edge(line(p0, p1), v0, v1, Some(style.clone()), Some(color));
let e1 = g.alloc_edge(line(p1, p2), v1, v2, Some(style.clone()), Some(color));
let e2 = g.alloc_edge(line(p2, p0), v2, v0, Some(style), Some(color));
let boundary = vec![
(e0, Direction::Forward),
(e1, Direction::Forward),
(e2, Direction::Forward),
];
let fill_color = ShapeColor::rgb(255, 0, 0);
let fid = g.alloc_fill(boundary, fill_color, FillRule::NonZero);
assert_eq!(g.fill(fid).boundary.len(), 3);
assert_eq!(g.fill(fid).color, fill_color);
}
// ── Adjacency ────────────────────────────────────────────────────────────
#[test]
fn edges_at_vertex_finds_incident() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(50.0, 50.0));
let v1 = g.alloc_vertex(Point::new(100.0, 50.0));
let v2 = g.alloc_vertex(Point::new(50.0, 100.0));
let v3 = g.alloc_vertex(Point::new(0.0, 50.0));
let e0 = g.alloc_edge(line(Point::new(50.0, 50.0), Point::new(100.0, 50.0)), v0, v1, None, None);
let e1 = g.alloc_edge(line(Point::new(50.0, 50.0), Point::new(50.0, 100.0)), v0, v2, None, None);
let _e2 = g.alloc_edge(line(Point::new(0.0, 50.0), Point::new(100.0, 50.0)), v3, v1, None, None);
let incident = g.edges_at_vertex(v0);
assert_eq!(incident.len(), 2);
assert!(incident.contains(&e0));
assert!(incident.contains(&e1));
}
#[test]
fn vertices_share_edge_check() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(0.0, 0.0));
let v1 = g.alloc_vertex(Point::new(10.0, 0.0));
let v2 = g.alloc_vertex(Point::new(20.0, 0.0));
g.alloc_edge(line(Point::ZERO, Point::new(10.0, 0.0)), v0, v1, None, None);
assert!(g.vertices_share_edge(v0, v1));
assert!(g.vertices_share_edge(v1, v0)); // symmetric
assert!(!g.vertices_share_edge(v0, v2));
}
// ── Edge visibility + deletion ───────────────────────────────────────────
#[test]
fn delete_visible_edge_without_fill_removes_it() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::ZERO);
let v1 = g.alloc_vertex(Point::new(10.0, 0.0));
let style = StrokeStyle { width: 1.0, ..Default::default() };
let e = g.alloc_edge(line(Point::ZERO, Point::new(10.0, 0.0)), v0, v1, Some(style), Some(ShapeColor::rgb(0, 0, 0)));
g.delete_edge_by_user(e);
assert!(g.edge(e).deleted);
}
#[test]
fn delete_edge_with_fill_makes_invisible() {
let mut g = VectorGraph::new();
// Triangle with a fill
let p0 = Point::new(0.0, 0.0);
let p1 = Point::new(100.0, 0.0);
let p2 = Point::new(50.0, 100.0);
let v0 = g.alloc_vertex(p0);
let v1 = g.alloc_vertex(p1);
let v2 = g.alloc_vertex(p2);
let style = StrokeStyle { width: 1.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let e0 = g.alloc_edge(line(p0, p1), v0, v1, Some(style.clone()), Some(color));
let e1 = g.alloc_edge(line(p1, p2), v1, v2, Some(style.clone()), Some(color));
let e2 = g.alloc_edge(line(p2, p0), v2, v0, Some(style), Some(color));
let boundary = vec![
(e0, Direction::Forward),
(e1, Direction::Forward),
(e2, Direction::Forward),
];
let _fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
// Delete one edge — should become invisible, not deleted
g.delete_edge_by_user(e0);
assert!(!g.edge(e0).deleted, "edge should not be deleted while fill references it");
assert!(!g.edge_is_visible(e0), "edge should be invisible");
}
#[test]
fn gc_removes_invisible_unreferenced_edges() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::ZERO);
let v1 = g.alloc_vertex(Point::new(10.0, 0.0));
// Invisible edge with no fill referencing it
let e = g.alloc_edge(line(Point::ZERO, Point::new(10.0, 0.0)), v0, v1, None, None);
assert!(!g.edge(e).deleted);
g.gc_invisible_edges();
assert!(g.edge(e).deleted, "invisible unreferenced edge should be garbage collected");
}
// ── BezPath generation ───────────────────────────────────────────────────
#[test]
fn fill_to_bezpath_generates_closed_path() {
let mut g = VectorGraph::new();
// Square
let tl = Point::new(0.0, 0.0);
let tr = Point::new(100.0, 0.0);
let br = Point::new(100.0, 100.0);
let bl = Point::new(0.0, 100.0);
let v_tl = g.alloc_vertex(tl);
let v_tr = g.alloc_vertex(tr);
let v_br = g.alloc_vertex(br);
let v_bl = g.alloc_vertex(bl);
let e0 = g.alloc_edge(line(tl, tr), v_tl, v_tr, None, None);
let e1 = g.alloc_edge(line(tr, br), v_tr, v_br, None, None);
let e2 = g.alloc_edge(line(br, bl), v_br, v_bl, None, None);
let e3 = g.alloc_edge(line(bl, tl), v_bl, v_tl, None, None);
let boundary = vec![
(e0, Direction::Forward),
(e1, Direction::Forward),
(e2, Direction::Forward),
(e3, Direction::Forward),
];
let fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
let path = g.fill_to_bezpath(fid);
let elements: Vec<_> = path.elements().to_vec();
// Should be: MoveTo, CurveTo x4, ClosePath
assert_eq!(elements.len(), 6);
assert!(matches!(elements[0], kurbo::PathEl::MoveTo(_)));
assert!(matches!(elements[5], kurbo::PathEl::ClosePath));
}
#[test]
fn fill_to_bezpath_respects_direction() {
let mut g = VectorGraph::new();
let p0 = Point::new(0.0, 0.0);
let p1 = Point::new(100.0, 0.0);
let v0 = g.alloc_vertex(p0);
let v1 = g.alloc_vertex(p1);
let e = g.alloc_edge(line(p0, p1), v0, v1, None, None);
// Forward: start at p0
let fwd_boundary = vec![(e, Direction::Forward)];
let fid_fwd = g.alloc_fill(fwd_boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
let path_fwd = g.fill_to_bezpath(fid_fwd);
if let kurbo::PathEl::MoveTo(start) = path_fwd.elements()[0] {
assert!((start.x - p0.x).abs() < 0.01);
}
// Backward: start at p1
let bwd_boundary = vec![(e, Direction::Backward)];
let fid_bwd = g.alloc_fill(bwd_boundary, ShapeColor::rgb(0, 255, 0), FillRule::NonZero);
let path_bwd = g.fill_to_bezpath(fid_bwd);
if let kurbo::PathEl::MoveTo(start) = path_bwd.elements()[0] {
assert!((start.x - p1.x).abs() < 0.01);
}
}

View File

@ -0,0 +1,322 @@
//! Vertex dragging, curve editing, edge deletion, and fill response.
use super::super::*;
use kurbo::{CubicBez, Point};
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
fn black_stroke() -> (Option<StrokeStyle>, Option<ShapeColor>) {
(Some(StrokeStyle { width: 2.0, ..Default::default() }), Some(ShapeColor::rgb(0, 0, 0)))
}
// ── Vertex dragging ──────────────────────────────────────────────────────
#[test]
fn drag_vertex_moves_connected_edges() {
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
// Two edges sharing a vertex at (50, 50)
// (0, 0) → (50, 50) → (100, 0)
g.insert_stroke(
&[
line(Point::new(0.0, 0.0), Point::new(50.0, 50.0)),
line(Point::new(50.0, 50.0), Point::new(100.0, 0.0)),
],
style, color, 0.5,
);
// Find the shared vertex at (50, 50)
let mid_v = g.vertices.iter().enumerate()
.find(|(_, v)| !v.deleted && (v.position.x - 50.0).abs() < 1.0 && (v.position.y - 50.0).abs() < 1.0)
.map(|(i, _)| VertexId(i as u32))
.expect("should find vertex at (50, 50)");
// Move it to (50, 80)
g.vertex_mut(mid_v).position = Point::new(50.0, 80.0);
g.update_edges_for_vertex(mid_v);
// Both edges incident to this vertex should have updated endpoints
let incident = g.edges_at_vertex(mid_v);
assert_eq!(incident.len(), 2);
for eid in incident {
let edge = g.edge(eid);
let v0_pos = g.vertex(edge.vertices[0]).position;
let v1_pos = g.vertex(edge.vertices[1]).position;
// One endpoint should be the moved vertex
assert!(
(v0_pos.x - 50.0).abs() < 1.0 && (v0_pos.y - 80.0).abs() < 1.0
|| (v1_pos.x - 50.0).abs() < 1.0 && (v1_pos.y - 80.0).abs() < 1.0,
"one endpoint should be the moved vertex at (50, 80)"
);
}
}
#[test]
fn drag_vertex_fill_follows() {
// Build a square, fill it, drag a corner — fill boundary should update
// because the fill references edges, edges reference vertices.
let mut g = VectorGraph::new();
let tl = Point::new(0.0, 0.0);
let tr = Point::new(100.0, 0.0);
let br = Point::new(100.0, 100.0);
let bl = Point::new(0.0, 100.0);
let v_tl = g.alloc_vertex(tl);
let v_tr = g.alloc_vertex(tr);
let v_br = g.alloc_vertex(br);
let v_bl = g.alloc_vertex(bl);
let e0 = g.alloc_edge(line(tl, tr), v_tl, v_tr, None, None);
let e1 = g.alloc_edge(line(tr, br), v_tr, v_br, None, None);
let e2 = g.alloc_edge(line(br, bl), v_br, v_bl, None, None);
let e3 = g.alloc_edge(line(bl, tl), v_bl, v_tl, None, None);
let boundary = vec![
(e0, Direction::Forward),
(e1, Direction::Forward),
(e2, Direction::Forward),
(e3, Direction::Forward),
];
let fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
// Drag top-right corner from (100, 0) to (150, 0)
g.vertex_mut(v_tr).position = Point::new(150.0, 0.0);
g.update_edges_for_vertex(v_tr);
// The fill's BezPath should reflect the moved vertex
let path = g.fill_to_bezpath(fid);
let bbox = kurbo::Shape::bounding_box(&path);
assert!(
bbox.max_x() > 120.0,
"fill bounding box should extend to the moved vertex, got max_x={:.1}",
bbox.max_x()
);
}
// ── Edge editing (# structure, editing a stub) ───────────────────────────
#[test]
fn edit_stub_in_hash_only_moves_that_segment() {
// # structure: 2 horizontal + 2 vertical lines
// Grab the top stub of the left vertical line and drag it.
// Only that sub-edge (above the top horizontal) should move.
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
g.insert_stroke(&[line(Point::new(0.0, 30.0), Point::new(100.0, 30.0))], style.clone(), color, 0.5);
g.insert_stroke(&[line(Point::new(0.0, 70.0), Point::new(100.0, 70.0))], style.clone(), color, 0.5);
g.insert_stroke(&[line(Point::new(30.0, 0.0), Point::new(30.0, 100.0))], style.clone(), color, 0.5);
g.insert_stroke(&[line(Point::new(70.0, 0.0), Point::new(70.0, 100.0))], style, color, 0.5);
// Find the top stub: the edge that goes from (30, 0) to (30, 30)
let stub_edge = g.edges.iter().enumerate().find(|(_, e)| {
if e.deleted { return false; }
let v0 = g.vertex(e.vertices[0]).position;
let v1 = g.vertex(e.vertices[1]).position;
// One endpoint near (30, 0), other near (30, 30)
let has_top = (v0.y < 5.0 && (v0.x - 30.0).abs() < 1.0)
|| (v1.y < 5.0 && (v1.x - 30.0).abs() < 1.0);
let has_junction = ((v0.y - 30.0).abs() < 1.0 && (v0.x - 30.0).abs() < 1.0)
|| ((v1.y - 30.0).abs() < 1.0 && (v1.x - 30.0).abs() < 1.0);
has_top && has_junction
}).map(|(i, _)| EdgeId(i as u32));
assert!(stub_edge.is_some(), "should find the top stub edge (30,0)→(30,30)");
// The stub is an independently selectable/editable sub-edge,
// not the full original vertical line.
let stub = g.edge(stub_edge.unwrap());
let v0_pos = g.vertex(stub.vertices[0]).position;
let v1_pos = g.vertex(stub.vertices[1]).position;
let length = ((v0_pos.x - v1_pos.x).powi(2) + (v0_pos.y - v1_pos.y).powi(2)).sqrt();
assert!(
(length - 30.0).abs() < 2.0,
"stub should be ~30 units long (from y=0 to y=30), got {length:.1}"
);
}
// ── Self-intersection creates new fill regions ───────────────────────────
#[test]
fn drag_o_into_figure_eight_splits_fill() {
// Start with a circle-like closed curve (approximated as a square for simplicity),
// fill it, then simulate dragging it into a figure-8 by creating a self-intersection.
let mut g = VectorGraph::new();
// Build a diamond shape: top, right, bottom, left
let top = Point::new(50.0, 0.0);
let right = Point::new(100.0, 50.0);
let bottom = Point::new(50.0, 100.0);
let left = Point::new(0.0, 50.0);
let v_top = g.alloc_vertex(top);
let v_right = g.alloc_vertex(right);
let v_bottom = g.alloc_vertex(bottom);
let v_left = g.alloc_vertex(left);
let style = StrokeStyle { width: 2.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let e0 = g.alloc_edge(line(top, right), v_top, v_right, Some(style.clone()), Some(color));
let e1 = g.alloc_edge(line(right, bottom), v_right, v_bottom, Some(style.clone()), Some(color));
let e2 = g.alloc_edge(line(bottom, left), v_bottom, v_left, Some(style.clone()), Some(color));
let e3 = g.alloc_edge(line(left, top), v_left, v_top, Some(style), Some(color));
let boundary = vec![
(e0, Direction::Forward),
(e1, Direction::Forward),
(e2, Direction::Forward),
(e3, Direction::Forward),
];
let _fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
// Simulate figure-8: drag top vertex down past center to (50, 70)
// and bottom vertex up past center to (50, 30).
// This causes edges e0/e3 (meeting at top) and e1/e2 (meeting at bottom)
// to cross, creating a self-intersection near the center.
g.vertex_mut(v_top).position = Point::new(50.0, 70.0);
g.update_edges_for_vertex(v_top);
g.vertex_mut(v_bottom).position = Point::new(50.0, 30.0);
g.update_edges_for_vertex(v_bottom);
// Detect and handle self-intersection.
// Edges e0 ((50,70)→(100,50)) and e2 ((50,30)→(0,50)) now cross.
// Edges e1 ((100,50)→(50,30)) and e3 ((0,50)→(50,70)) now cross.
// After detecting and splitting, the single fill should become two fills
// (the two lobes of the figure-8).
// TODO: This test documents the expected behavior. The implementation
// needs a "detect self-intersections in fill boundaries" pass that runs
// after vertex edits. For now, we test the expected outcome:
// - The crossing edges should be split at the intersection points
// - The original fill should be split into two fills
// - Both fills should inherit the original color
// For now, just verify the edges actually cross by checking that
// the diamond is now "inverted" (top below bottom)
assert!(
g.vertex(v_top).position.y > g.vertex(v_bottom).position.y,
"top vertex should now be below bottom vertex (figure-8)"
);
}
// ── Control point editing creates new intersections ──────────────────────
#[test]
fn edit_control_points_creates_intersections() {
// Draw a thin rectangle (0,0)-(100,20) with y-up convention.
// The bottom edge runs along y=0, the top edge along y=20.
// Edit the bottom edge's control points to bow it upward past y=20,
// crossing the top edge in two places.
let mut g = VectorGraph::new();
let bl = Point::new(0.0, 0.0);
let br = Point::new(100.0, 0.0);
let tr = Point::new(100.0, 20.0);
let tl = Point::new(0.0, 20.0);
let v_bl = g.alloc_vertex(bl);
let v_br = g.alloc_vertex(br);
let v_tr = g.alloc_vertex(tr);
let v_tl = g.alloc_vertex(tl);
let style = StrokeStyle { width: 2.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
// Bottom edge: (0,0) → (100,0) — the one we'll edit
let e_bottom = g.alloc_edge(line(bl, br), v_bl, v_br, Some(style.clone()), Some(color));
let e_right = g.alloc_edge(line(br, tr), v_br, v_tr, Some(style.clone()), Some(color));
// Top edge: (100,20) → (0,20)
let e_top = g.alloc_edge(line(tr, tl), v_tr, v_tl, Some(style.clone()), Some(color));
let e_left = g.alloc_edge(line(tl, bl), v_tl, v_bl, Some(style), Some(color));
let boundary = vec![
(e_bottom, Direction::Forward),
(e_right, Direction::Forward),
(e_top, Direction::Forward),
(e_left, Direction::Forward),
];
let _fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
// Edit the bottom edge's control points so it bows upward past y=20,
// crossing the top edge in two places.
// Endpoints stay at (0,0) and (100,0), control points go to (0,100) and (100,100).
g.edge_mut(e_bottom).curve = CubicBez::new(
bl,
Point::new(0.0, 100.0),
Point::new(100.0, 100.0),
br,
);
// The edited bottom curve now arcs up to ~y=75 at its peak,
// well past the top edge at y=20. It crosses the top edge twice.
// The implementation should:
// 1. Detect that e_bottom and e_top now intersect at 2 points
// 2. Split both edges at the intersection points
// 3. The original fill is split into 3 regions
// Verify the geometry: sample the edited curve at t=0.5 — should be well above y=20
let mid = kurbo::ParamCurve::eval(&g.edge(e_bottom).curve, 0.5);
assert!(
mid.y > 20.0,
"edited bottom curve should bow above y=20 (got y={:.1}), crossing the top edge",
mid.y
);
}
#[test]
fn edit_curve_into_self_intersection() {
// A single edge that is edited so it crosses itself.
// Start with a straight line, edit control points to create a loop.
let mut g = VectorGraph::new();
let p0 = Point::new(0.0, 0.0);
let p1 = Point::new(100.0, 0.0);
let v0 = g.alloc_vertex(p0);
let v1 = g.alloc_vertex(p1);
let style = StrokeStyle { width: 2.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let eid = g.alloc_edge(line(p0, p1), v0, v1, Some(style), Some(color));
// Edit control points to create a loop:
// The curve goes from (0,50), control points pull far left and far right
// at y=100, causing the curve to loop over itself.
g.edge_mut(eid).curve = CubicBez::new(
p0,
Point::new(150.0, 100.0),
Point::new(-50.0, 100.0),
p1,
);
// The implementation should detect the self-intersection, split the edge,
// and create a new vertex at the crossing. This forms a loop that is a
// fillable region.
// Verify the curve actually self-intersects by checking that it
// crosses x=50 more than twice (the loop causes extra crossings).
let mut crossings = 0;
let n = 100;
for i in 0..n {
let t0 = i as f64 / n as f64;
let t1 = (i + 1) as f64 / n as f64;
let x0 = kurbo::ParamCurve::eval(&g.edge(eid).curve, t0).x;
let x1 = kurbo::ParamCurve::eval(&g.edge(eid).curve, t1).x;
if (x0 - 50.0).signum() != (x1 - 50.0).signum() {
crossings += 1;
}
}
assert!(
crossings >= 3,
"edited curve should cross x=50 at least 3 times (self-intersecting), got {crossings}"
);
}

View File

@ -0,0 +1,399 @@
//! Paint bucket, fill splitting, fill persistence, and fill merging.
use super::super::*;
use kurbo::{CubicBez, Point};
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
fn black_stroke() -> (Option<StrokeStyle>, Option<ShapeColor>) {
(Some(StrokeStyle { width: 2.0, ..Default::default() }), Some(ShapeColor::rgb(0, 0, 0)))
}
/// Helper: insert a rectangle as 4 stroke segments, returning the graph.
fn make_rect(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorGraph {
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
let tl = Point::new(x0, y0);
let tr = Point::new(x1, y0);
let br = Point::new(x1, y1);
let bl = Point::new(x0, y1);
g.insert_stroke(
&[line(tl, tr), line(tr, br), line(br, bl), line(bl, tl)],
style,
color,
0.5,
);
g
}
// ── Paint bucket traces boundary and creates fill ────────────────────────
#[test]
fn paint_bucket_fills_rectangle() {
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
// Click inside the rectangle
let fid = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
);
assert!(fid.is_some(), "paint bucket should find and fill the rectangle");
let fid = fid.unwrap();
let fill = g.fill(fid);
assert_eq!(fill.boundary.len(), 4, "rectangle boundary should have 4 edges");
assert_eq!(fill.color, ShapeColor::rgb(255, 0, 0));
}
#[test]
fn paint_bucket_outside_rectangle_returns_none() {
let mut g = make_rect(100.0, 100.0, 200.0, 200.0);
// Click outside — no enclosed region
let fid = g.paint_bucket(
Point::new(0.0, 0.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
);
assert!(fid.is_none(), "paint bucket outside all curves should return None");
}
#[test]
fn paint_bucket_hash_fills_center_only() {
// # structure: the center square should be fillable without including the stubs
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
g.insert_stroke(&[line(Point::new(0.0, 30.0), Point::new(100.0, 30.0))], style.clone(), color, 0.5);
g.insert_stroke(&[line(Point::new(0.0, 70.0), Point::new(100.0, 70.0))], style.clone(), color, 0.5);
g.insert_stroke(&[line(Point::new(30.0, 0.0), Point::new(30.0, 100.0))], style.clone(), color, 0.5);
g.insert_stroke(&[line(Point::new(70.0, 0.0), Point::new(70.0, 100.0))], style, color, 0.5);
// Click in the center square (50, 50)
let fid = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(0, 0, 255),
FillRule::NonZero,
0.0,
);
assert!(fid.is_some(), "should fill the center square of the # pattern");
let fid = fid.unwrap();
let fill = g.fill(fid);
assert_eq!(fill.boundary.len(), 4, "center square should have exactly 4 boundary edges");
// Verify the fill region is small (the center square, not the whole #)
let path = g.fill_to_bezpath(fid);
let bbox = kurbo::Shape::bounding_box(&path);
assert!(bbox.width() < 50.0, "fill should be the center square, not the whole structure");
assert!(bbox.height() < 50.0);
}
// ── Fill splitting ───────────────────────────────────────────────────────
#[test]
fn draw_line_across_fill_splits_it() {
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
// Fill the rectangle
let fid = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
).expect("should fill");
// Draw a horizontal line through the middle, splitting the rectangle
let (style, color) = black_stroke();
let new_edges = g.insert_stroke(
&[line(Point::new(0.0, 50.0), Point::new(100.0, 50.0))],
style,
color,
0.5,
);
// The new line's endpoints should be at (0, 50) and (100, 50),
// where it intersects the left and right edges of the rectangle.
assert!(!new_edges.is_empty(), "insert_stroke should create at least one edge");
let first_edge = g.edge(*new_edges.first().unwrap());
let last_edge = g.edge(*new_edges.last().unwrap());
let start_pos = g.vertex(first_edge.vertices[0]).position;
let end_pos = g.vertex(last_edge.vertices[1]).position;
assert!(
(start_pos.x - 0.0).abs() < 1.0 && (start_pos.y - 50.0).abs() < 1.0,
"new line should start at (0, 50), got ({:.1}, {:.1})",
start_pos.x, start_pos.y,
);
assert!(
(end_pos.x - 100.0).abs() < 1.0 && (end_pos.y - 50.0).abs() < 1.0,
"new line should end at (100, 50), got ({:.1}, {:.1})",
end_pos.x, end_pos.y,
);
// The original fill should have been split into two fills
let live_fills: Vec<_> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted)
.collect();
assert_eq!(live_fills.len(), 2, "drawing a line across a fill should split it into 2");
// Both fills should inherit the original color
for (_, fill) in &live_fills {
assert_eq!(fill.color, ShapeColor::rgb(255, 0, 0));
}
}
#[test]
fn draw_line_not_through_fill_does_not_split() {
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
let _fid = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
).expect("should fill");
// Draw a line outside the rectangle — should not affect the fill
let (style, color) = black_stroke();
g.insert_stroke(
&[line(Point::new(200.0, 0.0), Point::new(200.0, 100.0))],
style,
color,
0.5,
);
let live_fills = g.fills.iter().filter(|f| !f.deleted).count();
assert_eq!(live_fills, 1, "line outside fill should not split it");
}
#[test]
fn draw_line_partially_across_fill_does_not_split() {
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
let fid = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
).expect("should fill");
// Draw a line that enters the fill but doesn't reach the other side:
// (0, 50) → (50, 50) — starts on the left edge, ends in the middle
let (style, color) = black_stroke();
g.insert_stroke(
&[line(Point::new(0.0, 50.0), Point::new(50.0, 50.0))],
style,
color,
0.5,
);
// The fill should NOT be split — the line only touches one boundary edge,
// not two. It's a spur (dead end) inside the fill.
let live_fills = g.fills.iter().filter(|f| !f.deleted).count();
assert_eq!(live_fills, 1, "line partially across fill should not split it");
// The fill should still reference a valid closed boundary
let fill = g.fill(fid);
assert!(!fill.deleted);
}
// ── Shared edges and concentric fills ────────────────────────────────────
#[test]
fn inner_square_reusing_edge_not_filled() {
// Outer square (0,0)-(100,100), a spur from (0,50)→(50,50),
// then an inner square (50,50)-(75,75). The spur connects the inner
// square to the outer boundary. Fill the outer square — the inner
// square should NOT be filled (it's a separate enclosed region).
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
let (style, color) = black_stroke();
// Spur: (0,50) → (50,50)
g.insert_stroke(
&[line(Point::new(0.0, 50.0), Point::new(50.0, 50.0))],
style.clone(), color, 0.5,
);
// Inner square: (50,50) → (75,50) → (75,75) → (50,75) → (50,50)
g.insert_stroke(
&[
line(Point::new(50.0, 50.0), Point::new(75.0, 50.0)),
line(Point::new(75.0, 50.0), Point::new(75.0, 75.0)),
line(Point::new(75.0, 75.0), Point::new(50.0, 75.0)),
line(Point::new(50.0, 75.0), Point::new(50.0, 50.0)),
],
style, color, 0.5,
);
// Fill outside the inner square but inside the outer square
let fid = g.paint_bucket(
Point::new(25.0, 25.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
).expect("should fill the outer region");
// The fill should NOT cover the inner square's interior
let path = g.fill_to_bezpath(fid);
// Point inside the inner square should be outside the fill path
assert_eq!(
kurbo::Shape::winding(&path, Point::new(62.0, 62.0)),
0,
"inner square interior should not be included in the outer fill"
);
// Point in the outer region should be inside the fill path
assert_ne!(
kurbo::Shape::winding(&path, Point::new(25.0, 25.0)),
0,
"outer region should be inside the fill"
);
}
#[test]
fn concentric_squares_fill_has_hole() {
// Outer square (0,0)-(100,100), inner square (25,25)-(75,75).
// No connecting edge — the two squares share no vertices.
// Filling the outer region should produce a fill with a hole
// (the inner square subtracts from the outer).
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
let (style, color) = black_stroke();
// Inner square, entirely inside the outer one
g.insert_stroke(
&[
line(Point::new(25.0, 25.0), Point::new(75.0, 25.0)),
line(Point::new(75.0, 25.0), Point::new(75.0, 75.0)),
line(Point::new(75.0, 75.0), Point::new(25.0, 75.0)),
line(Point::new(25.0, 75.0), Point::new(25.0, 25.0)),
],
style, color, 0.5,
);
// Fill between the two squares (click in the gap between them)
let fid = g.paint_bucket(
Point::new(10.0, 10.0),
ShapeColor::rgb(0, 255, 0),
FillRule::NonZero,
0.0,
).expect("should fill the annular region");
let path = g.fill_to_bezpath(fid);
// Point in the gap (between squares) should be inside the fill
assert_ne!(
kurbo::Shape::winding(&path, Point::new(10.0, 10.0)),
0,
"gap between squares should be filled"
);
// Point inside the inner square should NOT be filled
assert_eq!(
kurbo::Shape::winding(&path, Point::new(50.0, 50.0)),
0,
"inner square interior should be a hole in the fill"
);
}
// ── Fill persistence through edge deletion ───────────────────────────────
#[test]
fn fill_persists_when_edge_deleted() {
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
let fid = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
).expect("should fill");
// Delete one edge of the rectangle
let boundary_edge = g.fill(fid).boundary[0].0;
g.delete_edge_by_user(boundary_edge);
// Fill should still exist and still have the same boundary
assert!(!g.fill(fid).deleted, "fill should persist when its boundary edge is deleted");
assert_eq!(g.fill(fid).boundary.len(), 4, "fill boundary should be unchanged");
// The deleted edge should now be invisible but still exist
assert!(!g.edge(boundary_edge).deleted);
assert!(!g.edge_is_visible(boundary_edge));
}
#[test]
fn deleting_fill_then_gc_removes_invisible_edges() {
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
let fid = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
).expect("should fill");
// Make all edges invisible (user deleted the strokes)
let boundary_edges: Vec<EdgeId> = g.fill(fid).boundary.iter().map(|(e, _)| *e).collect();
for &eid in &boundary_edges {
g.make_edge_invisible(eid);
}
// Edges should still exist because fill references them
for &eid in &boundary_edges {
assert!(!g.edge(eid).deleted);
}
// Now delete the fill
g.free_fill(fid);
// GC should remove the invisible, now-unreferenced edges
g.gc_invisible_edges();
for &eid in &boundary_edges {
assert!(g.edge(eid).deleted, "invisible edge should be GC'd after fill deleted");
}
}
// ── Fill merging ─────────────────────────────────────────────────────────
#[test]
fn deleting_dividing_edge_merges_fills() {
let mut g = make_rect(0.0, 0.0, 100.0, 100.0);
// Fill the rectangle
let _fid = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
).expect("should fill");
// Draw a horizontal line through the middle to split the fill
let (style, color) = black_stroke();
let new_edges = g.insert_stroke(
&[line(Point::new(0.0, 50.0), Point::new(100.0, 50.0))],
style,
color,
0.5,
);
let live_fills_before = g.fills.iter().filter(|f| !f.deleted).count();
assert_eq!(live_fills_before, 2);
// Delete the dividing line — the two fills should merge back into one
// (The dividing edge endpoints are on the fill boundaries, making this detectable)
for eid in new_edges {
g.delete_edge_by_user(eid);
}
let live_fills_after = g.fills.iter().filter(|f| !f.deleted).count();
assert_eq!(live_fills_after, 1, "deleting the dividing edge should merge the two fills");
}

View File

@ -0,0 +1,271 @@
//! Gap tolerance fill tracing: invisible edges bridging small gaps,
//! mid-curve gap closing, and area-limiting behavior.
use super::super::*;
use kurbo::{CubicBez, Point};
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
fn black_stroke() -> (Option<StrokeStyle>, Option<ShapeColor>) {
(Some(StrokeStyle { width: 2.0, ..Default::default() }), Some(ShapeColor::rgb(0, 0, 0)))
}
// ── Endpoint gap closing ─────────────────────────────────────────────────
#[test]
fn gap_close_bridges_small_endpoint_gap() {
// Three sides of a rectangle with a small gap at the fourth corner.
// Without gap tolerance: no enclosed region.
// With gap tolerance: the gap is bridged by an invisible edge.
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
let tl = Point::new(0.0, 100.0);
let tr = Point::new(100.0, 100.0);
let br = Point::new(100.0, 0.0);
let bl = Point::new(0.0, 0.0);
let bl_gap = Point::new(3.0, 0.0); // 3px gap from true bottom-left
// Three complete sides + one side that stops 3px short
g.insert_stroke(&[line(tl, tr)], style.clone(), color, 0.5);
g.insert_stroke(&[line(tr, br)], style.clone(), color, 0.5);
g.insert_stroke(&[line(br, bl_gap)], style.clone(), color, 0.5);
g.insert_stroke(&[line(bl, tl)], style, color, 0.5);
// Without gap tolerance: should fail to find an enclosed region
let no_gap = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
0.0,
);
assert!(no_gap.is_none(), "should not find enclosed region with zero gap tolerance");
// With gap tolerance of 5px: should bridge the 3px gap
let with_gap = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
5.0,
);
assert!(with_gap.is_some(), "should bridge the 3px gap with 5px tolerance");
// The bridge should be a real invisible edge in the graph
let fid = with_gap.unwrap();
let fill = g.fill(fid);
let has_invisible_boundary_edge = fill.boundary.iter().any(|(eid, _)| {
!g.edge_is_visible(*eid)
});
assert!(has_invisible_boundary_edge, "gap-close should create an invisible edge");
}
#[test]
fn gap_close_does_not_bridge_large_gap() {
// Same as above but with a 20px gap — should not bridge with 5px tolerance.
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
let tl = Point::new(0.0, 100.0);
let tr = Point::new(100.0, 100.0);
let br = Point::new(100.0, 0.0);
let bl = Point::new(0.0, 0.0);
let bl_gap = Point::new(20.0, 0.0); // 20px gap
g.insert_stroke(&[line(tl, tr)], style.clone(), color, 0.5);
g.insert_stroke(&[line(tr, br)], style.clone(), color, 0.5);
g.insert_stroke(&[line(br, bl_gap)], style.clone(), color, 0.5);
g.insert_stroke(&[line(bl, tl)], style, color, 0.5);
let result = g.paint_bucket(
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
5.0,
);
assert!(result.is_none(), "should not bridge a 20px gap with 5px tolerance");
}
// ── Mid-curve gap closing: )( pattern ────────────────────────────────────
#[test]
fn gap_close_mid_curve_parentheses() {
// Two opposing arcs forming a )( shape, with caps at top and bottom
// so the ends are closed. The closest approach is at the midpoints
// of the arcs (~5px gap). Gap-close should bridge there, and filling
// on one side should only fill that half — not the full eye shape.
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
// Left arc: ) shape — endpoints at (40, 0) and (40, 100), bowing right to x≈55
g.insert_stroke(
&[CubicBez::new(
Point::new(40.0, 0.0),
Point::new(60.0, 0.0),
Point::new(60.0, 100.0),
Point::new(40.0, 100.0),
)],
style.clone(), color, 0.5,
);
// Right arc: ( shape — endpoints at (70, 0) and (70, 100), bowing left to x≈59
// (must stay right of left arc's max x≈55 to avoid crossing)
g.insert_stroke(
&[CubicBez::new(
Point::new(70.0, 0.0),
Point::new(55.0, 0.0),
Point::new(55.0, 100.0),
Point::new(70.0, 100.0),
)],
style.clone(), color, 0.5,
);
// Cap the top: (40, 0) → (70, 0)
g.insert_stroke(&[line(Point::new(40.0, 0.0), Point::new(70.0, 0.0))], style.clone(), color, 0.5);
// Cap the bottom: (40, 100) → (70, 100)
g.insert_stroke(&[line(Point::new(40.0, 100.0), Point::new(70.0, 100.0))], style, color, 0.5);
// The full shape is an eye/lens. The mid-curve gap (~3.75px at y≈50)
// divides it into left and right halves.
// With gap tolerance of 10px, clicking in the LEFT half should only
// fill the left half — the gap-close bridge at the midpoints acts
// as a dividing edge.
// The bridge divides the eye horizontally at y≈50.
// The eye interior is between the two arcs (at y=25, roughly x=53..60).
// Click between the arcs in the upper half.
let fid = g.paint_bucket(
Point::new(57.0, 25.0), // between arcs, upper half
ShapeColor::rgb(0, 0, 255),
FillRule::NonZero,
10.0,
);
assert!(fid.is_some(), "should bridge mid-curve gap and fill one half");
let fid = fid.unwrap();
let path = g.fill_to_bezpath(fid);
let bbox = kurbo::Shape::bounding_box(&path);
// The fill should cover only one half of the eye shape.
// The full eye spans y=0..100, so one half should be roughly y=0..50.
assert!(
bbox.height() < 60.0,
"fill should only cover one half of the eye, got height={:.1}",
bbox.height()
);
// The other half should NOT be filled
assert_eq!(
kurbo::Shape::winding(&path, Point::new(57.0, 75.0)),
0,
"other half of the eye should not be filled"
);
}
// ── Acute corner: gap-close should NOT cut across ────────────────────────
#[test]
fn gap_close_does_not_shortcut_acute_corner() {
// Two edges meeting at a sharp acute angle at vertex (50, 0).
// Near the vertex, the edges are close together, but they are connected —
// gap-close should NOT bridge between them.
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
// Two lines meeting at a sharp angle at (50, 0)
// Left arm: (0, 50) → (50, 0)
// Right arm: (50, 0) → (100, 50)
g.insert_stroke(
&[
line(Point::new(0.0, 50.0), Point::new(50.0, 0.0)),
line(Point::new(50.0, 0.0), Point::new(100.0, 50.0)),
],
style.clone(), color, 0.5,
);
// Close off the bottom to form a triangle
g.insert_stroke(
&[line(Point::new(0.0, 50.0), Point::new(100.0, 50.0))],
style, color, 0.5,
);
// Fill the triangle with generous gap tolerance — the fill should go all
// the way into the acute corner at (50, 0), not shortcut across it.
let fid = g.paint_bucket(
Point::new(50.0, 30.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
10.0,
).expect("should fill the triangle");
// The fill should reach the apex at (50, 0)
let path = g.fill_to_bezpath(fid);
let bbox = kurbo::Shape::bounding_box(&path);
assert!(
bbox.min_y() < 2.0,
"fill should reach the apex near y=0, got min_y={:.1}",
bbox.min_y()
);
}
// ── Gap tolerance as area limiter ────────────────────────────────────────
#[test]
fn gap_close_prefers_smallest_enclosing_region() {
// A large rectangle with a small rectangle inside it.
// The small rectangle has a gap. With gap tolerance, the user clicks
// inside the small rectangle — should fill the small rectangle,
// NOT the large one (even though the large one is also reachable
// through the gap).
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
// Large rectangle: (0, 0) → (200, 200)
g.insert_stroke(
&[
line(Point::new(0.0, 0.0), Point::new(200.0, 0.0)),
line(Point::new(200.0, 0.0), Point::new(200.0, 200.0)),
line(Point::new(200.0, 200.0), Point::new(0.0, 200.0)),
line(Point::new(0.0, 200.0), Point::new(0.0, 0.0)),
],
style.clone(), color, 0.5,
);
// Small rectangle inside: (80, 80) → (120, 120), with a 3px gap
g.insert_stroke(
&[
line(Point::new(80.0, 80.0), Point::new(120.0, 80.0)),
line(Point::new(120.0, 80.0), Point::new(120.0, 120.0)),
line(Point::new(120.0, 120.0), Point::new(83.0, 120.0)), // stops 3px short
],
style.clone(), color, 0.5,
);
g.insert_stroke(
&[line(Point::new(80.0, 120.0), Point::new(80.0, 80.0))],
style, color, 0.5,
);
// Click inside the small rectangle with gap tolerance
let fid = g.paint_bucket(
Point::new(100.0, 100.0),
ShapeColor::rgb(255, 0, 0),
FillRule::NonZero,
5.0,
).expect("should fill with gap tolerance");
// The fill should be the small rectangle, not the large one
let path = g.fill_to_bezpath(fid);
let bbox = kurbo::Shape::bounding_box(&path);
assert!(
bbox.width() < 60.0 && bbox.height() < 60.0,
"should fill the small rectangle (~40x40), not the large one (~200x200), got {:.0}x{:.0}",
bbox.width(),
bbox.height()
);
}

View File

@ -0,0 +1,10 @@
#[cfg(test)]
mod basic;
#[cfg(test)]
mod stroke;
#[cfg(test)]
mod fill;
#[cfg(test)]
mod editing;
#[cfg(test)]
mod gap_close;

View File

@ -0,0 +1,237 @@
//! Stroke insertion with intersection detection and curve splitting.
use super::super::*;
use kurbo::{CubicBez, Point};
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
fn black_stroke() -> (Option<StrokeStyle>, Option<ShapeColor>) {
(Some(StrokeStyle { width: 2.0, ..Default::default() }), Some(ShapeColor::rgb(0, 0, 0)))
}
// ── Single stroke ────────────────────────────────────────────────────────
#[test]
fn insert_single_line_creates_edge() {
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
let edges = g.insert_stroke(
&[line(Point::new(0.0, 0.0), Point::new(100.0, 0.0))],
style,
color,
0.5,
);
assert_eq!(edges.len(), 1);
assert!(g.edge_is_visible(edges[0]));
// Should have 2 vertices (endpoints)
let live_verts = g.vertices.iter().filter(|v| !v.deleted).count();
assert_eq!(live_verts, 2);
}
#[test]
fn insert_multi_segment_stroke_creates_chain() {
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
let segments = vec![
line(Point::new(0.0, 0.0), Point::new(50.0, 0.0)),
line(Point::new(50.0, 0.0), Point::new(100.0, 50.0)),
line(Point::new(100.0, 50.0), Point::new(100.0, 100.0)),
];
let edges = g.insert_stroke(&segments, style, color, 0.5);
assert_eq!(edges.len(), 3);
// Should have 4 vertices (start + 2 intermediate + end)
let live_verts = g.vertices.iter().filter(|v| !v.deleted).count();
assert_eq!(live_verts, 4);
}
#[test]
fn insert_stroke_snaps_to_existing_vertex() {
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
// First stroke: (0,0) → (100,0)
g.insert_stroke(
&[line(Point::new(0.0, 0.0), Point::new(100.0, 0.0))],
style.clone(),
color,
0.5,
);
// Second stroke starts very close to (100,0) — should snap, not create new vertex
g.insert_stroke(
&[line(Point::new(100.2, 0.1), Point::new(100.0, 100.0))],
style,
color,
0.5,
);
// Should have 3 vertices, not 4 (the near-endpoint was snapped)
let live_verts = g.vertices.iter().filter(|v| !v.deleted).count();
assert_eq!(live_verts, 3);
}
// ── Intersection splitting ───────────────────────────────────────────────
#[test]
fn crossing_strokes_creates_intersection_vertex() {
// Two perpendicular lines forming a +
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
// Horizontal: (0, 50) → (100, 50)
g.insert_stroke(
&[line(Point::new(0.0, 50.0), Point::new(100.0, 50.0))],
style.clone(),
color,
0.5,
);
// Vertical: (50, 0) → (50, 100) — crosses the horizontal at (50, 50)
g.insert_stroke(
&[line(Point::new(50.0, 0.0), Point::new(50.0, 100.0))],
style,
color,
0.5,
);
// The horizontal should have been split into 2 edges
// The vertical should be split into 2 edges
// Total: 4 edges, 5 vertices (4 endpoints + 1 intersection)
let live_edges = g.edges.iter().filter(|e| !e.deleted).count();
let live_verts = g.vertices.iter().filter(|v| !v.deleted).count();
assert_eq!(live_edges, 4, "two lines crossing = 4 sub-edges");
assert_eq!(live_verts, 5, "4 endpoints + 1 intersection vertex");
// The intersection vertex should be near (50, 50)
let intersection_v = g.vertices.iter().find(|v| {
!v.deleted
&& (v.position.x - 50.0).abs() < 1.0
&& (v.position.y - 50.0).abs() < 1.0
});
assert!(intersection_v.is_some(), "should have a vertex near (50, 50)");
}
#[test]
fn hash_structure_four_crossing_edges() {
// Four lines creating a # pattern:
// Two horizontal, two vertical — 4 intersection points
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
// Horizontal lines
g.insert_stroke(
&[line(Point::new(0.0, 30.0), Point::new(100.0, 30.0))],
style.clone(), color, 0.5,
);
g.insert_stroke(
&[line(Point::new(0.0, 70.0), Point::new(100.0, 70.0))],
style.clone(), color, 0.5,
);
// Vertical lines — each crosses both horizontals
g.insert_stroke(
&[line(Point::new(30.0, 0.0), Point::new(30.0, 100.0))],
style.clone(), color, 0.5,
);
g.insert_stroke(
&[line(Point::new(70.0, 0.0), Point::new(70.0, 100.0))],
style, color, 0.5,
);
// 4 intersection vertices + 8 endpoints = 12 vertices
// Each of the 4 original lines is split into 3 sub-edges = 12 edges
let live_verts = g.vertices.iter().filter(|v| !v.deleted).count();
let live_edges = g.edges.iter().filter(|e| !e.deleted).count();
assert_eq!(live_verts, 12);
assert_eq!(live_edges, 12);
}
#[test]
fn self_intersecting_stroke_splits() {
// A curve that crosses itself (figure-8 like).
// We approximate with line segments forming an X.
let mut g = VectorGraph::new();
let (style, color) = black_stroke();
let segments = vec![
line(Point::new(0.0, 0.0), Point::new(100.0, 100.0)),
line(Point::new(100.0, 100.0), Point::new(100.0, 0.0)),
line(Point::new(100.0, 0.0), Point::new(0.0, 100.0)),
];
let edges = g.insert_stroke(&segments, style, color, 0.5);
// The first segment (0,0)→(100,100) and third segment (100,0)→(0,100)
// cross near (50, 50). This splits both into 2 sub-edges each,
// plus the middle segment (100,100)→(100,0) is untouched.
// Total: 2 + 1 + 2 = 5 edges, 4 corners + 1 self-intersection = 5 vertices.
let live_verts = g.vertices.iter().filter(|v| !v.deleted).count();
let live_edges = g.edges.iter().filter(|e| !e.deleted).count();
assert_eq!(
live_verts, 5,
"should have 5 vertices (4 corners + 1 self-intersection), got {live_verts}"
);
assert_eq!(
live_edges, 5,
"should have 5 edges (2 split + 1 unsplit + 2 split), got {live_edges}"
);
}
// ── Edge splitting preserves fills ───────────────────────────────────────
#[test]
fn split_edge_updates_fill_boundary() {
let mut g = VectorGraph::new();
// Build a square manually
let tl = Point::new(0.0, 0.0);
let tr = Point::new(100.0, 0.0);
let br = Point::new(100.0, 100.0);
let bl = Point::new(0.0, 100.0);
let v_tl = g.alloc_vertex(tl);
let v_tr = g.alloc_vertex(tr);
let v_br = g.alloc_vertex(br);
let v_bl = g.alloc_vertex(bl);
let e_top = g.alloc_edge(line(tl, tr), v_tl, v_tr, None, None);
let e_right = g.alloc_edge(line(tr, br), v_tr, v_br, None, None);
let e_bottom = g.alloc_edge(line(br, bl), v_br, v_bl, None, None);
let e_left = g.alloc_edge(line(bl, tl), v_bl, v_tl, None, None);
let boundary = vec![
(e_top, Direction::Forward),
(e_right, Direction::Forward),
(e_bottom, Direction::Forward),
(e_left, Direction::Forward),
];
let fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
// Split the top edge at t=0.5
let (_mid_v, sub_a, sub_b) = g.split_edge(e_top, 0.5);
// The fill should now reference sub_a and sub_b instead of e_top
let fill = g.fill(fid);
assert_eq!(fill.boundary.len(), 5, "boundary should grow from 4 to 5 edges");
assert!(
fill.boundary.iter().any(|(eid, _)| *eid == sub_a),
"fill should reference first sub-edge"
);
assert!(
fill.boundary.iter().any(|(eid, _)| *eid == sub_b),
"fill should reference second sub-edge"
);
assert!(
!fill.boundary.iter().any(|(eid, _)| *eid == e_top),
"fill should no longer reference the original edge"
);
}