rewrite vector backend again
This commit is contained in:
parent
b8f847e167
commit
c9a9c2c5f0
|
|
@ -139,13 +139,26 @@ fn find_intersections_recursive(
|
||||||
t2 = (t2 + dt2).clamp(0.0, 1.0);
|
t2 = (t2 + dt2).clamp(0.0, 1.0);
|
||||||
}
|
}
|
||||||
// If Newton diverged far from the initial estimate, it may have
|
// 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
|
if (t1 - t1_orig).abs() > (t1_end - t1_start) * 2.0
|
||||||
|| (t2 - t2_orig).abs() > (t2_end - t2_start) * 2.0
|
|| (t2 - t2_orig).abs() > (t2_end - t2_start) * 2.0
|
||||||
{
|
{
|
||||||
|
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;
|
t1 = t1_orig;
|
||||||
t2 = t2_orig;
|
t2 = t2_orig;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let p1 = orig_curve1.eval(t1);
|
let p1 = orig_curve1.eval(t1);
|
||||||
let p2 = orig_curve2.eval(t2);
|
let p2 = orig_curve2.eval(t2);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ pub(crate) mod clipboard_platform;
|
||||||
pub mod region_select;
|
pub mod region_select;
|
||||||
pub mod dcel2;
|
pub mod dcel2;
|
||||||
pub use dcel2 as dcel;
|
pub use dcel2 as dcel;
|
||||||
|
pub mod vector_graph;
|
||||||
pub mod svg_export;
|
pub mod svg_export;
|
||||||
pub mod snap;
|
pub mod snap;
|
||||||
pub mod webcam;
|
pub mod webcam;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue