rewrite dcel
This commit is contained in:
parent
1621602f41
commit
dc27cf253d
|
|
@ -5,7 +5,7 @@
|
|||
//! maintained such that wherever two strokes intersect there is a vertex.
|
||||
|
||||
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point};
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Shape as KurboShape};
|
||||
use rstar::{PointDistance, RTree, RTreeObject, AABB};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
|
@ -774,6 +774,274 @@ impl Dcel {
|
|||
path
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Region queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Return all non-deleted, non-unbounded faces whose interior lies inside `region`.
|
||||
///
|
||||
/// For each face, a representative interior point is found by offsetting from
|
||||
/// a boundary edge midpoint along the inward-facing normal (the face lies to
|
||||
/// the left of its boundary half-edges). This works for concave/crescent faces
|
||||
/// where a simple centroid could land outside the face.
|
||||
// -----------------------------------------------------------------------
|
||||
// Region extraction (split DCEL by vertex classification)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Extract the portion of the DCEL inside a closed region path.
|
||||
///
|
||||
/// After inserting the region boundary via `insert_stroke()`, all crossing
|
||||
/// edges have been split at intersection points. This method classifies
|
||||
/// every vertex as INSIDE, OUTSIDE, or BOUNDARY (on the region path),
|
||||
/// then:
|
||||
/// - In a clone (`extracted`): removes edges with any OUTSIDE endpoint
|
||||
/// - In `self`: removes edges with any INSIDE endpoint
|
||||
///
|
||||
/// Boundary edges (both endpoints on the boundary) are kept in **both**.
|
||||
/// Returns the extracted (inside) DCEL.
|
||||
pub fn extract_region(&mut self, region: &BezPath, epsilon: f64) -> Dcel {
|
||||
// Step 1: Classify every non-deleted vertex
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum VClass { Inside, Outside, Boundary }
|
||||
|
||||
let classifications: Vec<VClass> = self.vertices.iter().map(|v| {
|
||||
if v.deleted {
|
||||
return VClass::Outside; // doesn't matter, won't be referenced
|
||||
}
|
||||
// Check distance to region path
|
||||
let pos = v.position;
|
||||
if Self::point_distance_to_path(pos, region) < epsilon {
|
||||
VClass::Boundary
|
||||
} else if region.winding(pos) != 0 {
|
||||
VClass::Inside
|
||||
} else {
|
||||
VClass::Outside
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Step 2: Clone self → extracted
|
||||
let mut extracted = self.clone();
|
||||
|
||||
// Step 3: In extracted, remove edges where either endpoint is OUTSIDE
|
||||
let edges_to_remove_from_extracted: Vec<EdgeId> = extracted.edges.iter().enumerate()
|
||||
.filter_map(|(i, edge)| {
|
||||
if edge.deleted { return None; }
|
||||
let edge_id = EdgeId(i as u32);
|
||||
let [he_fwd, he_bwd] = edge.half_edges;
|
||||
let v1 = extracted.half_edges[he_fwd.idx()].origin;
|
||||
let v2 = extracted.half_edges[he_bwd.idx()].origin;
|
||||
if classifications[v1.idx()] == VClass::Outside
|
||||
|| classifications[v2.idx()] == VClass::Outside
|
||||
{
|
||||
Some(edge_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for edge_id in edges_to_remove_from_extracted {
|
||||
if !extracted.edges[edge_id.idx()].deleted {
|
||||
extracted.remove_edge(edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: In self, remove edges where either endpoint is INSIDE
|
||||
let edges_to_remove_from_self: Vec<EdgeId> = self.edges.iter().enumerate()
|
||||
.filter_map(|(i, edge)| {
|
||||
if edge.deleted { return None; }
|
||||
let edge_id = EdgeId(i as u32);
|
||||
let [he_fwd, he_bwd] = edge.half_edges;
|
||||
let v1 = self.half_edges[he_fwd.idx()].origin;
|
||||
let v2 = self.half_edges[he_bwd.idx()].origin;
|
||||
if classifications[v1.idx()] == VClass::Inside
|
||||
|| classifications[v2.idx()] == VClass::Inside
|
||||
{
|
||||
Some(edge_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for edge_id in edges_to_remove_from_self {
|
||||
if !self.edges[edge_id.idx()].deleted {
|
||||
self.remove_edge(edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
extracted
|
||||
}
|
||||
|
||||
/// Propagate fill properties from a snapshot DCEL to faces that lost them
|
||||
/// during `insert_stroke` (e.g., when region boundary edges split a filled face
|
||||
/// but the new sub-face didn't inherit the fill).
|
||||
///
|
||||
/// For each unfilled face, finds a robust interior sample point (centroid with
|
||||
/// winding-check, or inward-normal offset fallback), then looks it up in the
|
||||
/// snapshot to determine what fill it should have.
|
||||
pub fn propagate_fills(&mut self, snapshot: &Dcel) {
|
||||
use kurbo::ParamCurveDeriv;
|
||||
|
||||
for i in 1..self.faces.len() {
|
||||
let face = &self.faces[i];
|
||||
if face.deleted || face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Skip faces that already have fill
|
||||
if face.fill_color.is_some() || face.image_fill.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let face_id = FaceId(i as u32);
|
||||
let boundary = self.face_boundary(face_id);
|
||||
if boundary.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let face_path = self.halfedges_to_bezpath(&boundary);
|
||||
|
||||
// Strategy 1: Use the centroid of boundary vertices. For convex faces
|
||||
// (common after region splitting), this is guaranteed to be interior.
|
||||
let mut cx = 0.0;
|
||||
let mut cy = 0.0;
|
||||
let mut n_verts = 0;
|
||||
for &he_id in &boundary {
|
||||
let he = &self.half_edges[he_id.idx()];
|
||||
let v = &self.vertices[he.origin.idx()];
|
||||
cx += v.position.x;
|
||||
cy += v.position.y;
|
||||
n_verts += 1;
|
||||
}
|
||||
let mut sample_point = None;
|
||||
if n_verts > 0 {
|
||||
let centroid = Point::new(cx / n_verts as f64, cy / n_verts as f64);
|
||||
if face_path.winding(centroid) != 0 {
|
||||
sample_point = Some(centroid);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Inward-normal offset from edge midpoints (fallback for
|
||||
// non-convex faces where the centroid falls outside).
|
||||
if sample_point.is_none() {
|
||||
let epsilon = 0.5;
|
||||
for &he_id in &boundary {
|
||||
let he = &self.half_edges[he_id.idx()];
|
||||
let edge = &self.edges[he.edge.idx()];
|
||||
let is_forward = edge.half_edges[0] == he_id;
|
||||
let curve = if is_forward {
|
||||
edge.curve
|
||||
} else {
|
||||
CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0)
|
||||
};
|
||||
|
||||
let mid = curve.eval(0.5);
|
||||
let tangent = curve.deriv().eval(0.5);
|
||||
let len = (tangent.x * tangent.x + tangent.y * tangent.y).sqrt();
|
||||
if len < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let nx = tangent.y / len;
|
||||
let ny = -tangent.x / len;
|
||||
let candidate = Point::new(mid.x + nx * epsilon, mid.y + ny * epsilon);
|
||||
if face_path.winding(candidate) != 0 {
|
||||
sample_point = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sample = match sample_point {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Look up which face this interior point was in the snapshot
|
||||
let snap_face_id = snapshot.find_face_containing_point(sample);
|
||||
if snap_face_id.0 == 0 {
|
||||
continue;
|
||||
}
|
||||
let snap_face = &snapshot.faces[snap_face_id.idx()];
|
||||
if snap_face.fill_color.is_some() || snap_face.image_fill.is_some() {
|
||||
self.faces[i].fill_color = snap_face.fill_color.clone();
|
||||
self.faces[i].image_fill = snap_face.image_fill;
|
||||
self.faces[i].fill_rule = snap_face.fill_rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the minimum distance from a point to a BezPath (treated as a polyline/curve).
|
||||
fn point_distance_to_path(point: Point, path: &BezPath) -> f64 {
|
||||
use kurbo::PathEl;
|
||||
|
||||
let mut min_dist = f64::MAX;
|
||||
let mut current = Point::ZERO;
|
||||
let mut subpath_start = Point::ZERO;
|
||||
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
current = p;
|
||||
subpath_start = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let d = Self::point_to_line_segment_dist(point, current, p);
|
||||
if d < min_dist { min_dist = d; }
|
||||
current = p;
|
||||
}
|
||||
PathEl::QuadTo(cp, p) => {
|
||||
// Approximate as cubic
|
||||
let cp1 = Point::new(
|
||||
current.x + 2.0 / 3.0 * (cp.x - current.x),
|
||||
current.y + 2.0 / 3.0 * (cp.y - current.y),
|
||||
);
|
||||
let cp2 = Point::new(
|
||||
p.x + 2.0 / 3.0 * (cp.x - p.x),
|
||||
p.y + 2.0 / 3.0 * (cp.y - p.y),
|
||||
);
|
||||
let cubic = CubicBez::new(current, cp1, cp2, p);
|
||||
let nearest = cubic.nearest(point, 0.5);
|
||||
let d = nearest.distance_sq.sqrt();
|
||||
if d < min_dist { min_dist = d; }
|
||||
current = p;
|
||||
}
|
||||
PathEl::CurveTo(cp1, cp2, p) => {
|
||||
let cubic = CubicBez::new(current, cp1, cp2, p);
|
||||
let nearest = cubic.nearest(point, 0.5);
|
||||
let d = nearest.distance_sq.sqrt();
|
||||
if d < min_dist { min_dist = d; }
|
||||
current = p;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if (current.x - subpath_start.x).abs() > 1e-10
|
||||
|| (current.y - subpath_start.y).abs() > 1e-10
|
||||
{
|
||||
let d = Self::point_to_line_segment_dist(point, current, subpath_start);
|
||||
if d < min_dist { min_dist = d; }
|
||||
}
|
||||
current = subpath_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
min_dist
|
||||
}
|
||||
|
||||
/// Distance from a point to a line segment.
|
||||
fn point_to_line_segment_dist(point: Point, a: Point, b: Point) -> f64 {
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let len_sq = dx * dx + dy * dy;
|
||||
if len_sq < 1e-20 {
|
||||
return ((point.x - a.x).powi(2) + (point.y - a.y).powi(2)).sqrt();
|
||||
}
|
||||
let t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / len_sq;
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let proj_x = a.x + t * dx;
|
||||
let proj_y = a.y + t * dy;
|
||||
((point.x - proj_x).powi(2) + (point.y - proj_y).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation (debug)
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -4664,4 +4932,85 @@ mod tests {
|
|||
|
||||
dump_all_faces(&d, "After stroke 4");
|
||||
}
|
||||
|
||||
/// Reproduce the user's test case: rectangle (100,100)-(200,200),
|
||||
/// region select (0,0)-(150,150). The overlap corner face should be
|
||||
/// detected as inside the region.
|
||||
#[test]
|
||||
fn test_extract_region_rectangle_corner() {
|
||||
use crate::region_select::line_to_cubic;
|
||||
use kurbo::{Line, Shape as _};
|
||||
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Draw a rectangle from (100,100) to (200,200) as 4 line strokes
|
||||
let rect_sides = [
|
||||
Line::new(Point::new(100.0, 100.0), Point::new(200.0, 100.0)),
|
||||
Line::new(Point::new(200.0, 100.0), Point::new(200.0, 200.0)),
|
||||
Line::new(Point::new(200.0, 200.0), Point::new(100.0, 200.0)),
|
||||
Line::new(Point::new(100.0, 200.0), Point::new(100.0, 100.0)),
|
||||
];
|
||||
for side in &rect_sides {
|
||||
let seg = line_to_cubic(side);
|
||||
dcel.insert_stroke(&[seg], None, None, 1.0);
|
||||
}
|
||||
|
||||
// Set fill on the rectangle face (simulating paint bucket)
|
||||
let rect_face = dcel.find_face_containing_point(Point::new(150.0, 150.0));
|
||||
assert!(rect_face.0 != 0, "rectangle face should be bounded");
|
||||
dcel.face_mut(rect_face).fill_color = Some(ShapeColor::new(255, 0, 0, 255));
|
||||
|
||||
// Region select rectangle from (0,0) to (150,150) — overlaps top-left corner
|
||||
let region_sides = [
|
||||
Line::new(Point::new(0.0, 0.0), Point::new(150.0, 0.0)),
|
||||
Line::new(Point::new(150.0, 0.0), Point::new(150.0, 150.0)),
|
||||
Line::new(Point::new(150.0, 150.0), Point::new(0.0, 150.0)),
|
||||
Line::new(Point::new(0.0, 150.0), Point::new(0.0, 0.0)),
|
||||
];
|
||||
let region_segments: Vec<CubicBez> = region_sides.iter().map(|l| line_to_cubic(l)).collect();
|
||||
let snapshot = dcel.clone();
|
||||
dcel.insert_stroke(®ion_segments, None, None, 1.0);
|
||||
|
||||
// Build the region path for extract_region
|
||||
let mut region_path = BezPath::new();
|
||||
region_path.move_to(Point::new(0.0, 0.0));
|
||||
region_path.line_to(Point::new(150.0, 0.0));
|
||||
region_path.line_to(Point::new(150.0, 150.0));
|
||||
region_path.line_to(Point::new(0.0, 150.0));
|
||||
region_path.close_path();
|
||||
|
||||
// Extract, then propagate fills on extracted only (remainder keeps
|
||||
// its fills from the original data — no propagation needed there).
|
||||
let mut extracted = dcel.extract_region(®ion_path, 1.0);
|
||||
extracted.propagate_fills(&snapshot);
|
||||
|
||||
// The extracted DCEL should have at least one face with fill (the corner overlap)
|
||||
let extracted_filled_faces: Vec<_> = extracted.faces.iter().enumerate()
|
||||
.filter(|(i, f)| !f.deleted && *i > 0 && f.fill_color.is_some())
|
||||
.collect();
|
||||
assert!(
|
||||
!extracted_filled_faces.is_empty(),
|
||||
"Extracted DCEL should have at least one filled face (the corner overlap)"
|
||||
);
|
||||
|
||||
// The original DCEL (remainder) should still have filled faces (the L-shaped remainder)
|
||||
let remainder_filled_faces: Vec<_> = dcel.faces.iter().enumerate()
|
||||
.filter(|(i, f)| !f.deleted && *i > 0 && f.fill_color.is_some())
|
||||
.collect();
|
||||
assert!(
|
||||
!remainder_filled_faces.is_empty(),
|
||||
"Remainder DCEL should have at least one filled face (L-shaped remainder)"
|
||||
);
|
||||
|
||||
// The empty-space face in the remainder (outside the original rectangle)
|
||||
// should NOT have fill — verify no spurious fill propagation
|
||||
let point_outside_rect = Point::new(50.0, 50.0);
|
||||
let face_at_outside = dcel.find_face_containing_point(point_outside_rect);
|
||||
if face_at_outside.0 != 0 && !dcel.face(face_at_outside).deleted {
|
||||
assert!(
|
||||
dcel.face(face_at_outside).fill_color.is_none(),
|
||||
"Face at (50,50) should NOT have fill — it's outside the original rectangle"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,569 @@
|
|||
//! Doubly-Connected Edge List (DCEL) for planar subdivision vector drawing.
|
||||
//!
|
||||
//! Each vector layer keyframe stores a DCEL representing a Flash-style planar
|
||||
//! subdivision. Strokes live on edges, fills live on faces, and the topology is
|
||||
//! maintained such that wherever two strokes intersect there is a vertex.
|
||||
//!
|
||||
//! Half-edges leaving a vertex are maintained in sorted CCW order. This enables
|
||||
//! efficient face detection by ray-casting to the nearest edge and walking CCW.
|
||||
|
||||
pub mod topology;
|
||||
pub mod query;
|
||||
pub mod stroke;
|
||||
pub mod region;
|
||||
|
||||
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
||||
use kurbo::{CubicBez, Point};
|
||||
use rstar::{PointDistance, RTree, RTreeObject, AABB};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! define_id {
|
||||
($name:ident) => {
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct $name(pub u32);
|
||||
|
||||
impl $name {
|
||||
pub const NONE: Self = Self(u32::MAX);
|
||||
|
||||
#[inline]
|
||||
pub fn is_none(self) -> bool {
|
||||
self.0 == u32::MAX
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn idx(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.is_none() {
|
||||
write!(f, "{}(NONE)", stringify!($name))
|
||||
} else {
|
||||
write!(f, "{}({})", stringify!($name), self.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_id!(VertexId);
|
||||
define_id!(HalfEdgeId);
|
||||
define_id!(EdgeId);
|
||||
define_id!(FaceId);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A vertex in the DCEL.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Vertex {
|
||||
pub position: Point,
|
||||
/// One outgoing half-edge (any one; iteration via twin.next gives the CCW fan).
|
||||
/// NONE if the vertex is isolated (no edges).
|
||||
pub outgoing: HalfEdgeId,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// A half-edge in the DCEL.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HalfEdge {
|
||||
pub origin: VertexId,
|
||||
pub twin: HalfEdgeId,
|
||||
/// Next half-edge around the face (CCW).
|
||||
pub next: HalfEdgeId,
|
||||
/// Previous half-edge around the face (CCW).
|
||||
pub prev: HalfEdgeId,
|
||||
/// Face to the left of this half-edge.
|
||||
pub face: FaceId,
|
||||
/// Parent edge (shared between this half-edge and its twin).
|
||||
pub edge: EdgeId,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// Geometric and style data for an edge (shared by the two half-edges).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct EdgeData {
|
||||
/// The two half-edges: [forward, backward].
|
||||
/// Forward goes from curve.p0 to curve.p3.
|
||||
pub half_edges: [HalfEdgeId; 2],
|
||||
pub curve: CubicBez,
|
||||
pub stroke_style: Option<StrokeStyle>,
|
||||
pub stroke_color: Option<ShapeColor>,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// A face (region) in the DCEL.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Face {
|
||||
/// One half-edge on the outer boundary. NONE for the unbounded face (face 0).
|
||||
pub outer_half_edge: HalfEdgeId,
|
||||
/// Half-edges on inner boundary cycles (holes).
|
||||
pub inner_half_edges: Vec<HalfEdgeId>,
|
||||
pub fill_color: Option<ShapeColor>,
|
||||
pub image_fill: Option<uuid::Uuid>,
|
||||
pub fill_rule: FillRule,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spatial index for vertex snapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VertexEntry {
|
||||
pub id: VertexId,
|
||||
pub position: [f64; 2],
|
||||
}
|
||||
|
||||
impl RTreeObject for VertexEntry {
|
||||
type Envelope = AABB<[f64; 2]>;
|
||||
fn envelope(&self) -> Self::Envelope {
|
||||
AABB::from_point(self.position)
|
||||
}
|
||||
}
|
||||
|
||||
impl PointDistance for VertexEntry {
|
||||
fn distance_2(&self, point: &[f64; 2]) -> f64 {
|
||||
let dx = self.position[0] - point[0];
|
||||
let dy = self.position[1] - point[1];
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debug recorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DebugRecorder {
|
||||
pub strokes: Vec<Vec<CubicBez>>,
|
||||
pub paint_points: Vec<Point>,
|
||||
}
|
||||
|
||||
impl DebugRecorder {
|
||||
pub fn record_stroke(&mut self, segments: &[CubicBez]) {
|
||||
self.strokes.push(segments.to_vec());
|
||||
}
|
||||
|
||||
pub fn record_paint(&mut self, point: Point) {
|
||||
self.paint_points.push(point);
|
||||
}
|
||||
|
||||
pub fn dump_test(&self, name: &str) {
|
||||
eprintln!(" #[test]");
|
||||
eprintln!(" fn {name}() {{");
|
||||
eprintln!(" let mut dcel = Dcel::new();");
|
||||
eprintln!();
|
||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||
eprintln!(" // Stroke {i}");
|
||||
eprintln!(" dcel.insert_stroke(&[");
|
||||
for seg in stroke {
|
||||
eprintln!(
|
||||
" CubicBez::new(Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1})),",
|
||||
seg.p0.x, seg.p0.y, seg.p1.x, seg.p1.y,
|
||||
seg.p2.x, seg.p2.y, seg.p3.x, seg.p3.y,
|
||||
);
|
||||
}
|
||||
eprintln!(" ], None, None, 5.0);");
|
||||
eprintln!();
|
||||
}
|
||||
for (i, pt) in self.paint_points.iter().enumerate() {
|
||||
eprintln!(" // Paint {i}");
|
||||
eprintln!(
|
||||
" let _f{i} = dcel.find_face_at_point(Point::new({:.1}, {:.1}));",
|
||||
pt.x, pt.y
|
||||
);
|
||||
}
|
||||
eprintln!(" }}");
|
||||
}
|
||||
|
||||
pub fn dump_and_reset(&mut self, name: &str) {
|
||||
self.dump_test(name);
|
||||
self.strokes.clear();
|
||||
self.paint_points.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default snap epsilon in document coordinate units.
|
||||
pub const DEFAULT_SNAP_EPSILON: f64 = 0.5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DCEL container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Dcel {
|
||||
pub vertices: Vec<Vertex>,
|
||||
pub half_edges: Vec<HalfEdge>,
|
||||
pub edges: Vec<EdgeData>,
|
||||
pub faces: Vec<Face>,
|
||||
|
||||
free_vertices: Vec<u32>,
|
||||
free_half_edges: Vec<u32>,
|
||||
free_edges: Vec<u32>,
|
||||
free_faces: Vec<u32>,
|
||||
|
||||
#[serde(skip)]
|
||||
vertex_rtree: Option<RTree<VertexEntry>>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub debug_recorder: Option<DebugRecorder>,
|
||||
}
|
||||
|
||||
impl Default for Dcel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Dcel {
|
||||
/// Create a new empty DCEL with just the unbounded outer face (face 0).
|
||||
pub fn new() -> Self {
|
||||
let unbounded = Face {
|
||||
outer_half_edge: HalfEdgeId::NONE,
|
||||
inner_half_edges: Vec::new(),
|
||||
fill_color: None,
|
||||
image_fill: None,
|
||||
fill_rule: FillRule::NonZero,
|
||||
deleted: false,
|
||||
};
|
||||
let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() {
|
||||
Some(DebugRecorder::default())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Dcel {
|
||||
vertices: Vec::new(),
|
||||
half_edges: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
faces: vec![unbounded],
|
||||
free_vertices: Vec::new(),
|
||||
free_half_edges: Vec::new(),
|
||||
free_edges: Vec::new(),
|
||||
free_faces: Vec::new(),
|
||||
vertex_rtree: None,
|
||||
debug_recorder,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Debug recording
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn set_recording(&mut self, enabled: bool) {
|
||||
if enabled {
|
||||
self.debug_recorder.get_or_insert_with(DebugRecorder::default);
|
||||
} else {
|
||||
self.debug_recorder = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.debug_recorder.is_some()
|
||||
}
|
||||
|
||||
pub fn dump_recorded_test(&mut self, name: &str) {
|
||||
if let Some(ref mut rec) = self.debug_recorder {
|
||||
rec.dump_and_reset(name);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_paint_point(&mut self, point: Point) {
|
||||
if let Some(ref mut rec) = self.debug_recorder {
|
||||
rec.record_paint(point);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allocation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn alloc_vertex(&mut self, position: Point) -> VertexId {
|
||||
let id = if let Some(idx) = self.free_vertices.pop() {
|
||||
let id = VertexId(idx);
|
||||
self.vertices[id.idx()] = Vertex {
|
||||
position,
|
||||
outgoing: HalfEdgeId::NONE,
|
||||
deleted: false,
|
||||
};
|
||||
id
|
||||
} else {
|
||||
let id = VertexId(self.vertices.len() as u32);
|
||||
self.vertices.push(Vertex {
|
||||
position,
|
||||
outgoing: HalfEdgeId::NONE,
|
||||
deleted: false,
|
||||
});
|
||||
id
|
||||
};
|
||||
self.vertex_rtree = None;
|
||||
id
|
||||
}
|
||||
|
||||
pub fn alloc_half_edge_pair(&mut self) -> (HalfEdgeId, HalfEdgeId) {
|
||||
let tombstone = HalfEdge {
|
||||
origin: VertexId::NONE,
|
||||
twin: HalfEdgeId::NONE,
|
||||
next: HalfEdgeId::NONE,
|
||||
prev: HalfEdgeId::NONE,
|
||||
face: FaceId::NONE,
|
||||
edge: EdgeId::NONE,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
let alloc_one = |dcel: &mut Dcel| -> HalfEdgeId {
|
||||
if let Some(idx) = dcel.free_half_edges.pop() {
|
||||
let id = HalfEdgeId(idx);
|
||||
dcel.half_edges[id.idx()] = tombstone.clone();
|
||||
id
|
||||
} else {
|
||||
let id = HalfEdgeId(dcel.half_edges.len() as u32);
|
||||
dcel.half_edges.push(tombstone.clone());
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
let a = alloc_one(self);
|
||||
let b = alloc_one(self);
|
||||
self.half_edges[a.idx()].twin = b;
|
||||
self.half_edges[b.idx()].twin = a;
|
||||
(a, b)
|
||||
}
|
||||
|
||||
pub fn alloc_edge(&mut self, curve: CubicBez) -> EdgeId {
|
||||
let data = EdgeData {
|
||||
half_edges: [HalfEdgeId::NONE, HalfEdgeId::NONE],
|
||||
curve,
|
||||
stroke_style: None,
|
||||
stroke_color: None,
|
||||
deleted: false,
|
||||
};
|
||||
if let Some(idx) = self.free_edges.pop() {
|
||||
let id = EdgeId(idx);
|
||||
self.edges[id.idx()] = data;
|
||||
id
|
||||
} else {
|
||||
let id = EdgeId(self.edges.len() as u32);
|
||||
self.edges.push(data);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alloc_face(&mut self) -> FaceId {
|
||||
let face = Face {
|
||||
outer_half_edge: HalfEdgeId::NONE,
|
||||
inner_half_edges: Vec::new(),
|
||||
fill_color: None,
|
||||
image_fill: None,
|
||||
fill_rule: FillRule::NonZero,
|
||||
deleted: false,
|
||||
};
|
||||
if let Some(idx) = self.free_faces.pop() {
|
||||
let id = FaceId(idx);
|
||||
self.faces[id.idx()] = face;
|
||||
id
|
||||
} else {
|
||||
let id = FaceId(self.faces.len() as u32);
|
||||
self.faces.push(face);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Deallocation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn free_vertex(&mut self, id: VertexId) {
|
||||
debug_assert!(!id.is_none());
|
||||
self.vertices[id.idx()].deleted = true;
|
||||
self.free_vertices.push(id.0);
|
||||
self.vertex_rtree = None;
|
||||
}
|
||||
|
||||
pub fn free_half_edge(&mut self, id: HalfEdgeId) {
|
||||
debug_assert!(!id.is_none());
|
||||
self.half_edges[id.idx()].deleted = true;
|
||||
self.free_half_edges.push(id.0);
|
||||
}
|
||||
|
||||
pub fn free_edge(&mut self, id: EdgeId) {
|
||||
debug_assert!(!id.is_none());
|
||||
self.edges[id.idx()].deleted = true;
|
||||
self.free_edges.push(id.0);
|
||||
}
|
||||
|
||||
pub fn free_face(&mut self, id: FaceId) {
|
||||
debug_assert!(!id.is_none());
|
||||
debug_assert!(id.0 != 0, "cannot free the unbounded face");
|
||||
self.faces[id.idx()].deleted = true;
|
||||
self.free_faces.push(id.0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[inline]
|
||||
pub fn vertex(&self, id: VertexId) -> &Vertex {
|
||||
&self.vertices[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn vertex_mut(&mut self, id: VertexId) -> &mut Vertex {
|
||||
&mut self.vertices[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn half_edge(&self, id: HalfEdgeId) -> &HalfEdge {
|
||||
&self.half_edges[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn half_edge_mut(&mut self, id: HalfEdgeId) -> &mut HalfEdge {
|
||||
&mut self.half_edges[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn edge(&self, id: EdgeId) -> &EdgeData {
|
||||
&self.edges[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn edge_mut(&mut self, id: EdgeId) -> &mut EdgeData {
|
||||
&mut self.edges[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn face(&self, id: FaceId) -> &Face {
|
||||
&self.faces[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn face_mut(&mut self, id: FaceId) -> &mut Face {
|
||||
&mut self.faces[id.idx()]
|
||||
}
|
||||
|
||||
/// Destination vertex of a half-edge (origin of its twin).
|
||||
#[inline]
|
||||
pub fn half_edge_dest(&self, he: HalfEdgeId) -> VertexId {
|
||||
let twin = self.half_edge(he).twin;
|
||||
self.half_edge(twin).origin
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bezier utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Split a cubic bezier at parameter t using de Casteljau's algorithm.
|
||||
pub fn subdivide_cubic(c: CubicBez, t: f64) -> (CubicBez, CubicBez) {
|
||||
let p01 = lerp_point(c.p0, c.p1, t);
|
||||
let p12 = lerp_point(c.p1, c.p2, t);
|
||||
let p23 = lerp_point(c.p2, c.p3, t);
|
||||
let p012 = lerp_point(p01, p12, t);
|
||||
let p123 = lerp_point(p12, p23, t);
|
||||
let p0123 = lerp_point(p012, p123, t);
|
||||
(
|
||||
CubicBez::new(c.p0, p01, p012, p0123),
|
||||
CubicBez::new(p0123, p123, p23, c.p3),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract subsegment of a cubic bezier for parameter range [t0, t1].
|
||||
pub fn subsegment_cubic(c: CubicBez, t0: f64, t1: f64) -> CubicBez {
|
||||
if (t0).abs() < 1e-10 && (t1 - 1.0).abs() < 1e-10 {
|
||||
return c;
|
||||
}
|
||||
if (t0).abs() < 1e-10 {
|
||||
subdivide_cubic(c, t1).0
|
||||
} else if (t1 - 1.0).abs() < 1e-10 {
|
||||
subdivide_cubic(c, t0).1
|
||||
} else {
|
||||
let (_, upper) = subdivide_cubic(c, t0);
|
||||
let remapped_t1 = (t1 - t0) / (1.0 - t0);
|
||||
subdivide_cubic(upper, remapped_t1).0
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn lerp_point(a: Point, b: Point, t: f64) -> Point {
|
||||
Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
|
||||
}
|
||||
|
||||
/// Convert a `BezPath` into a list of sub-paths, each a `Vec<CubicBez>`.
|
||||
pub fn bezpath_to_cubic_segments(path: &kurbo::BezPath) -> Vec<Vec<CubicBez>> {
|
||||
use kurbo::PathEl;
|
||||
|
||||
let mut result: Vec<Vec<CubicBez>> = Vec::new();
|
||||
let mut current: Vec<CubicBez> = Vec::new();
|
||||
let mut subpath_start = Point::ZERO;
|
||||
let mut cursor = Point::ZERO;
|
||||
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if !current.is_empty() {
|
||||
result.push(std::mem::take(&mut current));
|
||||
}
|
||||
subpath_start = p;
|
||||
cursor = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let c1 = lerp_point(cursor, p, 1.0 / 3.0);
|
||||
let c2 = lerp_point(cursor, p, 2.0 / 3.0);
|
||||
current.push(CubicBez::new(cursor, c1, c2, p));
|
||||
cursor = p;
|
||||
}
|
||||
PathEl::QuadTo(p1, p2) => {
|
||||
let cp1 = Point::new(
|
||||
cursor.x + (2.0 / 3.0) * (p1.x - cursor.x),
|
||||
cursor.y + (2.0 / 3.0) * (p1.y - cursor.y),
|
||||
);
|
||||
let cp2 = Point::new(
|
||||
p2.x + (2.0 / 3.0) * (p1.x - p2.x),
|
||||
p2.y + (2.0 / 3.0) * (p1.y - p2.y),
|
||||
);
|
||||
current.push(CubicBez::new(cursor, cp1, cp2, p2));
|
||||
cursor = p2;
|
||||
}
|
||||
PathEl::CurveTo(p1, p2, p3) => {
|
||||
current.push(CubicBez::new(cursor, p1, p2, p3));
|
||||
cursor = p3;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
let dist = ((cursor.x - subpath_start.x).powi(2)
|
||||
+ (cursor.y - subpath_start.y).powi(2))
|
||||
.sqrt();
|
||||
if dist > 1e-9 {
|
||||
let c1 = lerp_point(cursor, subpath_start, 1.0 / 3.0);
|
||||
let c2 = lerp_point(cursor, subpath_start, 2.0 / 3.0);
|
||||
current.push(CubicBez::new(cursor, c1, c2, subpath_start));
|
||||
}
|
||||
cursor = subpath_start;
|
||||
if !current.is_empty() {
|
||||
result.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
result.push(current);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
//! Queries, iteration, and BezPath construction for the DCEL.
|
||||
|
||||
use super::{Dcel, EdgeId, FaceId, HalfEdgeId, VertexEntry, VertexId};
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, Point};
|
||||
use rstar::{PointDistance, RTree};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Result of a face-at-point query.
|
||||
pub struct FaceQuery {
|
||||
/// The face currently assigned to the cycle (may be F0 if no face was created).
|
||||
pub face: FaceId,
|
||||
/// A half-edge on the enclosing cycle. Walk via `next` to traverse.
|
||||
pub cycle_he: HalfEdgeId,
|
||||
}
|
||||
|
||||
impl Dcel {
|
||||
// -------------------------------------------------------------------
|
||||
// Iteration
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Walk the half-edge cycle starting at `start`, returning all half-edges.
|
||||
pub fn walk_cycle(&self, start: HalfEdgeId) -> Vec<HalfEdgeId> {
|
||||
let mut result = vec![start];
|
||||
let mut cur = self.half_edges[start.idx()].next;
|
||||
let mut steps = 0;
|
||||
while cur != start {
|
||||
result.push(cur);
|
||||
cur = self.half_edges[cur.idx()].next;
|
||||
steps += 1;
|
||||
debug_assert!(steps < 100_000, "infinite cycle in walk_cycle");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Get all half-edges on a face's outer boundary.
|
||||
pub fn face_boundary(&self, face_id: FaceId) -> Vec<HalfEdgeId> {
|
||||
let ohe = self.faces[face_id.idx()].outer_half_edge;
|
||||
if ohe.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.walk_cycle(ohe)
|
||||
}
|
||||
|
||||
/// Get all outgoing half-edges from a vertex in CCW order.
|
||||
pub fn vertex_outgoing(&self, vertex_id: VertexId) -> Vec<HalfEdgeId> {
|
||||
let start = self.vertices[vertex_id.idx()].outgoing;
|
||||
if start.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result = vec![start];
|
||||
let twin = self.half_edges[start.idx()].twin;
|
||||
let mut cur = self.half_edges[twin.idx()].next;
|
||||
let mut steps = 0;
|
||||
while cur != start {
|
||||
result.push(cur);
|
||||
let twin = self.half_edges[cur.idx()].twin;
|
||||
cur = self.half_edges[twin.idx()].next;
|
||||
steps += 1;
|
||||
debug_assert!(steps < 100_000, "infinite fan in vertex_outgoing");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Face detection
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Find the enclosing face/cycle for a point.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Find the nearest edge to `point`
|
||||
/// 2. Pick the half-edge with `point` on its left side (cross product of tangent)
|
||||
/// 3. Walk that half-edge's cycle — this is the innermost boundary enclosing `point`
|
||||
/// 4. Return the cycle's face (which may be F0 if no face has been created yet)
|
||||
/// along with a half-edge on the cycle so the caller can create a face if needed
|
||||
///
|
||||
/// Returns F0 with NONE cycle_he if there are no edges.
|
||||
pub fn find_face_at_point(&self, point: Point) -> FaceQuery {
|
||||
let mut best: Option<(EdgeId, f64, f64)> = None;
|
||||
|
||||
for (i, edge) in self.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
let nearest = edge.curve.nearest(point, 0.5);
|
||||
if best.is_none() || nearest.distance_sq < best.unwrap().1 {
|
||||
best = Some((EdgeId(i as u32), nearest.distance_sq, nearest.t));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((edge_id, _, t)) = best else {
|
||||
return FaceQuery {
|
||||
face: FaceId(0),
|
||||
cycle_he: HalfEdgeId::NONE,
|
||||
};
|
||||
};
|
||||
|
||||
let edge = &self.edges[edge_id.idx()];
|
||||
|
||||
// Tangent via finite difference (clamped to valid range)
|
||||
let t_lo = (t - 0.001).max(0.0);
|
||||
let t_hi = (t + 0.001).min(1.0);
|
||||
let p_lo = edge.curve.eval(t_lo);
|
||||
let p_hi = edge.curve.eval(t_hi);
|
||||
let tan_x = p_hi.x - p_lo.x;
|
||||
let tan_y = p_hi.y - p_lo.y;
|
||||
|
||||
let curve_pt = edge.curve.eval(t);
|
||||
let to_pt_x = point.x - curve_pt.x;
|
||||
let to_pt_y = point.y - curve_pt.y;
|
||||
let cross = tan_x * to_pt_y - tan_y * to_pt_x;
|
||||
|
||||
// cross > 0: point is to the left of the forward half-edge
|
||||
let he = if cross >= 0.0 {
|
||||
edge.half_edges[0]
|
||||
} else {
|
||||
edge.half_edges[1]
|
||||
};
|
||||
|
||||
// Walk the cycle to find the actual face
|
||||
let face = self.half_edges[he.idx()].face;
|
||||
|
||||
FaceQuery {
|
||||
face,
|
||||
cycle_he: he,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: just return the FaceId (backward-compatible).
|
||||
pub fn find_face_containing_point(&self, point: Point) -> FaceId {
|
||||
self.find_face_at_point(point).face
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Spatial index (vertex snapping)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
pub fn rebuild_spatial_index(&mut self) {
|
||||
let entries: Vec<VertexEntry> = self
|
||||
.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, v)| !v.deleted)
|
||||
.map(|(i, v)| VertexEntry {
|
||||
id: VertexId(i as u32),
|
||||
position: [v.position.x, v.position.y],
|
||||
})
|
||||
.collect();
|
||||
self.vertex_rtree = Some(RTree::bulk_load(entries));
|
||||
}
|
||||
|
||||
pub fn ensure_spatial_index(&mut self) {
|
||||
if self.vertex_rtree.is_none() {
|
||||
self.rebuild_spatial_index();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snap_vertex(&mut self, point: Point, epsilon: f64) -> Option<VertexId> {
|
||||
self.ensure_spatial_index();
|
||||
let tree = self.vertex_rtree.as_ref().unwrap();
|
||||
let query = [point.x, point.y];
|
||||
let nearest = tree.nearest_neighbor(&query)?;
|
||||
let dist_sq = nearest.distance_2(&query);
|
||||
if dist_sq <= epsilon * epsilon {
|
||||
Some(nearest.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// BezPath construction for rendering
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Raw bezpath from a face's outer boundary cycle.
|
||||
pub fn face_to_bezpath(&self, face_id: FaceId) -> BezPath {
|
||||
let cycle = self.face_boundary(face_id);
|
||||
self.cycle_to_bezpath(&cycle)
|
||||
}
|
||||
|
||||
/// Build a BezPath from a cycle of half-edges.
|
||||
pub fn cycle_to_bezpath(&self, cycle: &[HalfEdgeId]) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
if cycle.is_empty() {
|
||||
return path;
|
||||
}
|
||||
|
||||
let first_he = &self.half_edges[cycle[0].idx()];
|
||||
let first_pos = self.vertices[first_he.origin.idx()].position;
|
||||
path.move_to(first_pos);
|
||||
|
||||
for &he_id in cycle {
|
||||
let he = &self.half_edges[he_id.idx()];
|
||||
let edge = &self.edges[he.edge.idx()];
|
||||
if he_id == edge.half_edges[0] {
|
||||
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
|
||||
} else {
|
||||
path.curve_to(edge.curve.p2, edge.curve.p1, edge.curve.p0);
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// Bezpath with spur edges stripped (for fill rendering).
|
||||
pub fn face_to_bezpath_stripped(&self, face_id: FaceId) -> BezPath {
|
||||
let cycle = self.face_boundary(face_id);
|
||||
let stripped = self.strip_spurs(&cycle);
|
||||
self.cycle_to_bezpath(&stripped)
|
||||
}
|
||||
|
||||
/// Bezpath with outer boundary + reversed holes (for fill rendering).
|
||||
pub fn face_to_bezpath_with_holes(&self, face_id: FaceId) -> BezPath {
|
||||
let face = &self.faces[face_id.idx()];
|
||||
let mut path = self.face_to_bezpath_stripped(face_id);
|
||||
|
||||
let inner_hes: Vec<HalfEdgeId> = face.inner_half_edges.clone();
|
||||
for inner_he in inner_hes {
|
||||
if inner_he.is_none() || self.half_edges[inner_he.idx()].deleted {
|
||||
continue;
|
||||
}
|
||||
let inner_cycle = self.walk_cycle(inner_he);
|
||||
let stripped = self.strip_spurs(&inner_cycle);
|
||||
if stripped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Append hole reversed so winding rule cuts it out
|
||||
let reversed = self.cycle_to_bezpath_reversed(&stripped);
|
||||
for el in reversed.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => path.move_to(p),
|
||||
PathEl::LineTo(p) => path.line_to(p),
|
||||
PathEl::QuadTo(p1, p2) => path.quad_to(p1, p2),
|
||||
PathEl::CurveTo(p1, p2, p3) => path.curve_to(p1, p2, p3),
|
||||
PathEl::ClosePath => path.close_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Build a BezPath traversing a cycle in reverse direction.
|
||||
fn cycle_to_bezpath_reversed(&self, cycle: &[HalfEdgeId]) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
if cycle.is_empty() {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Start from the destination of the last half-edge
|
||||
let last_dest = self.half_edge_dest(*cycle.last().unwrap());
|
||||
let start_pos = self.vertices[last_dest.idx()].position;
|
||||
path.move_to(start_pos);
|
||||
|
||||
for &he_id in cycle.iter().rev() {
|
||||
let he = &self.half_edges[he_id.idx()];
|
||||
let edge = &self.edges[he.edge.idx()];
|
||||
if he_id == edge.half_edges[0] {
|
||||
// Was forward, now traversing backward
|
||||
path.curve_to(edge.curve.p2, edge.curve.p1, edge.curve.p0);
|
||||
} else {
|
||||
// Was backward, now traversing forward
|
||||
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// Strip spur (antenna) edges from a cycle.
|
||||
///
|
||||
/// A spur traverses an edge forward then immediately backward (or vice versa).
|
||||
/// Stack-based: push half-edges; if top shares the same edge as the new one,
|
||||
/// pop (cancel the pair).
|
||||
fn strip_spurs(&self, cycle: &[HalfEdgeId]) -> Vec<HalfEdgeId> {
|
||||
if cycle.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut stack: Vec<HalfEdgeId> = Vec::with_capacity(cycle.len());
|
||||
for &he in cycle {
|
||||
if let Some(&top) = stack.last() {
|
||||
if self.half_edges[top.idx()].edge == self.half_edges[he.idx()].edge {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
stack.push(he);
|
||||
}
|
||||
|
||||
// Handle wrap-around spurs at the seam
|
||||
while stack.len() >= 2 {
|
||||
let first_edge = self.half_edges[stack[0].idx()].edge;
|
||||
let last_edge = self.half_edges[stack.last().unwrap().idx()].edge;
|
||||
if first_edge == last_edge {
|
||||
stack.remove(0);
|
||||
stack.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stack
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Validation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Validate DCEL invariants. Panics with a descriptive message on failure.
|
||||
pub fn validate(&self) {
|
||||
// 1. Twin symmetry
|
||||
for (i, he) in self.half_edges.iter().enumerate() {
|
||||
if he.deleted { continue; }
|
||||
let id = HalfEdgeId(i as u32);
|
||||
let twin = he.twin;
|
||||
assert!(!twin.is_none(), "HE{i} has NONE twin");
|
||||
assert!(!self.half_edges[twin.idx()].deleted, "HE{i} twin is deleted");
|
||||
assert_eq!(self.half_edges[twin.idx()].twin, id, "HE{i} twin symmetry broken");
|
||||
}
|
||||
|
||||
// 2. Next/prev consistency
|
||||
for (i, he) in self.half_edges.iter().enumerate() {
|
||||
if he.deleted { continue; }
|
||||
let id = HalfEdgeId(i as u32);
|
||||
assert!(!he.next.is_none(), "HE{i} has NONE next");
|
||||
assert!(!he.prev.is_none(), "HE{i} has NONE prev");
|
||||
assert_eq!(self.half_edges[he.next.idx()].prev, id, "HE{i} next.prev != self");
|
||||
assert_eq!(self.half_edges[he.prev.idx()].next, id, "HE{i} prev.next != self");
|
||||
}
|
||||
|
||||
// 3. Face boundary consistency: all half-edges in a cycle share the same face
|
||||
let mut visited = HashSet::new();
|
||||
for (i, he) in self.half_edges.iter().enumerate() {
|
||||
if he.deleted { continue; }
|
||||
let id = HalfEdgeId(i as u32);
|
||||
if visited.contains(&id) { continue; }
|
||||
let cycle = self.walk_cycle(id);
|
||||
let face = he.face;
|
||||
for &cid in &cycle {
|
||||
assert_eq!(
|
||||
self.half_edges[cid.idx()].face, face,
|
||||
"HE{} face {:?} != cycle leader HE{i} face {:?}",
|
||||
cid.0, self.half_edges[cid.idx()].face, face
|
||||
);
|
||||
visited.insert(cid);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Vertex outgoing consistency
|
||||
for (i, v) in self.vertices.iter().enumerate() {
|
||||
if v.deleted || v.outgoing.is_none() { continue; }
|
||||
let he = &self.half_edges[v.outgoing.idx()];
|
||||
assert!(!he.deleted, "V{i} outgoing points to deleted HE");
|
||||
assert_eq!(he.origin, VertexId(i as u32), "V{i} outgoing.origin mismatch");
|
||||
}
|
||||
|
||||
// 5. Edge ↔ half-edge consistency
|
||||
for (i, edge) in self.edges.iter().enumerate() {
|
||||
if edge.deleted { continue; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
assert!(!fwd.is_none() && !bwd.is_none(), "E{i} has NONE half-edges");
|
||||
assert_eq!(self.half_edges[fwd.idx()].edge, EdgeId(i as u32), "E{i} fwd.edge mismatch");
|
||||
assert_eq!(self.half_edges[bwd.idx()].edge, EdgeId(i as u32), "E{i} bwd.edge mismatch");
|
||||
assert_eq!(self.half_edges[fwd.idx()].twin, bwd, "E{i} fwd.twin != bwd");
|
||||
}
|
||||
|
||||
// 6. Curve endpoint ↔ vertex position
|
||||
for (i, edge) in self.edges.iter().enumerate() {
|
||||
if edge.deleted { continue; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
let v_start = self.half_edges[fwd.idx()].origin;
|
||||
let v_end = self.half_edges[bwd.idx()].origin;
|
||||
let p_start = self.vertices[v_start.idx()].position;
|
||||
let p_end = self.vertices[v_end.idx()].position;
|
||||
let d0 = (p_start.x - edge.curve.p0.x).powi(2) + (p_start.y - edge.curve.p0.y).powi(2);
|
||||
let d3 = (p_end.x - edge.curve.p3.x).powi(2) + (p_end.y - edge.curve.p3.y).powi(2);
|
||||
assert!(d0 < 1.0, "E{i} p0 far from V{}", v_start.0);
|
||||
assert!(d3 < 1.0, "E{i} p3 far from V{}", v_end.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
//! Region extraction from the DCEL.
|
||||
//!
|
||||
//! `extract_region` splits a DCEL along a closed boundary path: the inside
|
||||
//! portion is returned as a new DCEL, the outside portion stays in `self`.
|
||||
//! Boundary edges are kept in both.
|
||||
//!
|
||||
//! Vertex classification is deterministic: boundary vertices are known from
|
||||
//! inserting the region stroke, all others are classified by winding number.
|
||||
//! Faces are classified by which vertices they touch — no sampling needed.
|
||||
|
||||
use super::{Dcel, EdgeId, FaceId, VertexId};
|
||||
use kurbo::{BezPath, Point, Shape};
|
||||
|
||||
/// Vertex classification relative to the region boundary.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum VClass {
|
||||
Inside,
|
||||
Outside,
|
||||
Boundary,
|
||||
}
|
||||
|
||||
impl Dcel {
|
||||
/// Extract the sub-DCEL inside a closed region path.
|
||||
///
|
||||
/// The caller must have already inserted the region boundary via
|
||||
/// `insert_stroke`, passing the resulting vertex IDs as `boundary_vertices`.
|
||||
/// All other vertices are classified by winding number against `region`.
|
||||
///
|
||||
/// Returns the extracted (inside) DCEL. `self` is modified to contain
|
||||
/// only the outside portion. Boundary edges appear in both.
|
||||
pub fn extract_region(
|
||||
&mut self,
|
||||
region: &BezPath,
|
||||
boundary_vertices: &[VertexId],
|
||||
) -> Dcel {
|
||||
let classifications = self.classify_vertices(region, boundary_vertices);
|
||||
|
||||
// Clone → extracted
|
||||
let mut extracted = self.clone();
|
||||
|
||||
// In extracted: remove edges where either endpoint is Outside
|
||||
let to_remove: Vec<EdgeId> = extracted
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, edge)| {
|
||||
if edge.deleted { return None; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
let v1 = extracted.half_edges[fwd.idx()].origin;
|
||||
let v2 = extracted.half_edges[bwd.idx()].origin;
|
||||
if classifications[v1.idx()] == VClass::Outside
|
||||
|| classifications[v2.idx()] == VClass::Outside
|
||||
{
|
||||
Some(EdgeId(i as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for edge_id in to_remove {
|
||||
if !extracted.edges[edge_id.idx()].deleted {
|
||||
extracted.remove_edge(edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
// In self: remove edges where either endpoint is Inside
|
||||
let to_remove: Vec<EdgeId> = self
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, edge)| {
|
||||
if edge.deleted { return None; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
let v1 = self.half_edges[fwd.idx()].origin;
|
||||
let v2 = self.half_edges[bwd.idx()].origin;
|
||||
if classifications[v1.idx()] == VClass::Inside
|
||||
|| classifications[v2.idx()] == VClass::Inside
|
||||
{
|
||||
Some(EdgeId(i as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for edge_id in to_remove {
|
||||
if !self.edges[edge_id.idx()].deleted {
|
||||
self.remove_edge(edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
extracted
|
||||
}
|
||||
|
||||
/// Classify every vertex as Inside, Outside, or Boundary.
|
||||
fn classify_vertices(
|
||||
&self,
|
||||
region: &BezPath,
|
||||
boundary_vertices: &[VertexId],
|
||||
) -> Vec<VClass> {
|
||||
self.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
if v.deleted {
|
||||
return VClass::Outside;
|
||||
}
|
||||
let vid = VertexId(i as u32);
|
||||
if boundary_vertices.contains(&vid) {
|
||||
VClass::Boundary
|
||||
} else if region.winding(v.position) != 0 {
|
||||
VClass::Inside
|
||||
} else {
|
||||
VClass::Outside
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Copy fill properties from `snapshot` to faces in `self` that lost
|
||||
/// them when the region boundary split filled faces.
|
||||
///
|
||||
/// For each unfilled face, walks its boundary to find an Inside vertex,
|
||||
/// then looks up the snapshot face at that vertex's position to inherit
|
||||
/// the fill. No sampling heuristic — vertex positions are exact.
|
||||
pub fn propagate_fills(
|
||||
&mut self,
|
||||
snapshot: &Dcel,
|
||||
region: &BezPath,
|
||||
boundary_vertices: &[VertexId],
|
||||
) {
|
||||
let classifications = self.classify_vertices(region, boundary_vertices);
|
||||
|
||||
for i in 1..self.faces.len() {
|
||||
let face = &self.faces[i];
|
||||
if face.deleted || face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
if face.fill_color.is_some() || face.image_fill.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let face_id = FaceId(i as u32);
|
||||
let boundary = self.face_boundary(face_id);
|
||||
|
||||
// Find an inside vertex on this face's boundary
|
||||
let probe = boundary.iter().find_map(|&he_id| {
|
||||
let vid = self.half_edges[he_id.idx()].origin;
|
||||
if classifications[vid.idx()] == VClass::Inside {
|
||||
Some(self.vertices[vid.idx()].position)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let probe_point = match probe {
|
||||
Some(p) => p,
|
||||
None => continue, // face has no inside vertices — skip
|
||||
};
|
||||
|
||||
let snap_face_id = snapshot.find_face_containing_point(probe_point);
|
||||
if snap_face_id.0 == 0 {
|
||||
continue;
|
||||
}
|
||||
let snap_face = &snapshot.faces[snap_face_id.idx()];
|
||||
if snap_face.fill_color.is_some() || snap_face.image_fill.is_some() {
|
||||
self.faces[i].fill_color = snap_face.fill_color.clone();
|
||||
self.faces[i].image_fill = snap_face.image_fill;
|
||||
self.faces[i].fill_rule = snap_face.fill_rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::{CubicBez, Point};
|
||||
|
||||
fn line_cubic(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,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_region_basic() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Two horizontal lines crossing the region boundary:
|
||||
// line A at y=30: (0,30) → (100,30)
|
||||
// line B at y=70: (0,70) → (100,70)
|
||||
let a0 = Point::new(0.0, 30.0);
|
||||
let a1 = Point::new(100.0, 30.0);
|
||||
let b0 = Point::new(0.0, 70.0);
|
||||
let b1 = Point::new(100.0, 70.0);
|
||||
|
||||
let va0 = dcel.alloc_vertex(a0);
|
||||
let va1 = dcel.alloc_vertex(a1);
|
||||
let vb0 = dcel.alloc_vertex(b0);
|
||||
let vb1 = dcel.alloc_vertex(b1);
|
||||
|
||||
dcel.insert_edge(va0, va1, FaceId(0), line_cubic(a0, a1));
|
||||
dcel.insert_edge(vb0, vb1, FaceId(0), line_cubic(b0, b1));
|
||||
|
||||
assert_eq!(dcel.edges.iter().filter(|e| !e.deleted).count(), 2);
|
||||
|
||||
// Region covers the left half: x ∈ [-10, 50]
|
||||
let mut region = BezPath::new();
|
||||
region.move_to(Point::new(-10.0, -10.0));
|
||||
region.line_to(Point::new(50.0, -10.0));
|
||||
region.line_to(Point::new(50.0, 110.0));
|
||||
region.line_to(Point::new(-10.0, 110.0));
|
||||
region.close_path();
|
||||
|
||||
// va0, vb0 are inside (x=0), va1, vb1 are outside (x=100)
|
||||
// No boundary vertices in this simple test
|
||||
let extracted = dcel.extract_region(®ion, &[]);
|
||||
|
||||
// Both edges have one inside and one outside endpoint,
|
||||
// so both are removed from both halves
|
||||
let self_edges = dcel.edges.iter().filter(|e| !e.deleted).count();
|
||||
let ext_edges = extracted.edges.iter().filter(|e| !e.deleted).count();
|
||||
assert_eq!(self_edges, 0, "edges span boundary → removed from self");
|
||||
assert_eq!(ext_edges, 0, "edges span boundary → removed from extracted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_region_with_boundary_vertices() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Build a horizontal line that will be split by the region boundary.
|
||||
// We simulate what happens after insert_stroke splits it:
|
||||
// left piece: (0,50) → (50,50) [inside → boundary]
|
||||
// right piece: (50,50) → (100,50) [boundary → outside]
|
||||
let p_left = Point::new(0.0, 50.0);
|
||||
let p_mid = Point::new(50.0, 50.0);
|
||||
let p_right = Point::new(100.0, 50.0);
|
||||
|
||||
let v_left = dcel.alloc_vertex(p_left);
|
||||
let v_mid = dcel.alloc_vertex(p_mid);
|
||||
let v_right = dcel.alloc_vertex(p_right);
|
||||
|
||||
dcel.insert_edge(v_left, v_mid, FaceId(0), line_cubic(p_left, p_mid));
|
||||
dcel.insert_edge(v_mid, v_right, FaceId(0), line_cubic(p_mid, p_right));
|
||||
|
||||
// Region: left half (x < 50)
|
||||
let mut region = BezPath::new();
|
||||
region.move_to(Point::new(-10.0, -10.0));
|
||||
region.line_to(Point::new(50.0, -10.0));
|
||||
region.line_to(Point::new(50.0, 110.0));
|
||||
region.line_to(Point::new(-10.0, 110.0));
|
||||
region.close_path();
|
||||
|
||||
// v_mid is on the boundary
|
||||
let extracted = dcel.extract_region(®ion, &[v_mid]);
|
||||
|
||||
// Left edge: inside → boundary → kept in extracted
|
||||
// Right edge: boundary → outside → kept in self
|
||||
let ext_edges = extracted.edges.iter().filter(|e| !e.deleted).count();
|
||||
let self_edges = dcel.edges.iter().filter(|e| !e.deleted).count();
|
||||
assert_eq!(ext_edges, 1, "extracted should have left edge");
|
||||
assert_eq!(self_edges, 1, "self should have right edge");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,897 @@
|
|||
//! High-level stroke insertion into the DCEL.
|
||||
//!
|
||||
//! `insert_stroke` is the main entry point for the Draw tool.
|
||||
//!
|
||||
//! For each new stroke segment, we find intersections with existing edges and
|
||||
//! immediately split both curves at the intersection point, sharing a single
|
||||
//! vertex. This avoids the problem where batch-processing gives slightly
|
||||
//! different intersection positions for the same crossing.
|
||||
|
||||
use super::{
|
||||
subsegment_cubic, Dcel, EdgeId, FaceId, HalfEdgeId, VertexId, DEFAULT_SNAP_EPSILON,
|
||||
};
|
||||
use crate::curve_intersections::{find_curve_intersections, Intersection};
|
||||
use crate::shape::{ShapeColor, StrokeStyle};
|
||||
use kurbo::{CubicBez, ParamCurve, Point};
|
||||
|
||||
pub struct InsertStrokeResult {
|
||||
pub new_vertices: Vec<VertexId>,
|
||||
pub new_edges: Vec<EdgeId>,
|
||||
pub split_edges: Vec<(EdgeId, f64, VertexId, EdgeId)>,
|
||||
pub new_faces: Vec<FaceId>,
|
||||
}
|
||||
|
||||
/// A split point along a stroke segment, in stroke-parameter order.
|
||||
#[derive(Debug, Clone)]
|
||||
struct SegmentSplit {
|
||||
/// Parameter on the stroke segment where the split occurs.
|
||||
t: f64,
|
||||
/// The vertex at the split point (already created by splitting the existing edge).
|
||||
vertex: VertexId,
|
||||
}
|
||||
|
||||
/// Endpoint proximity threshold: intersections this close to an endpoint
|
||||
/// are filtered (vertex snapping handles them instead).
|
||||
const ENDPOINT_T_MARGIN: f64 = 0.01;
|
||||
|
||||
impl Dcel {
|
||||
/// For a single stroke segment, find all intersections with existing edges.
|
||||
/// For each intersection, immediately split the existing edge and create a
|
||||
/// shared vertex. Returns the split points sorted by t along the segment.
|
||||
///
|
||||
/// For each existing edge, we find ALL intersections at once, then split
|
||||
/// that edge at all of them (high-t to low-t, remapping t values as the
|
||||
/// edge shortens). This correctly handles a stroke segment crossing the
|
||||
/// same edge multiple times.
|
||||
fn intersect_and_split_segment(
|
||||
&mut self,
|
||||
segment: &CubicBez,
|
||||
result: &mut InsertStrokeResult,
|
||||
) -> Vec<SegmentSplit> {
|
||||
let mut splits: Vec<SegmentSplit> = Vec::new();
|
||||
|
||||
// Snapshot edge count. Tail edges created by split_edge are portions
|
||||
// of edges we already found all intersections for, so they don't need
|
||||
// re-checking.
|
||||
let edge_count = self.edges.len();
|
||||
|
||||
for edge_idx in 0..edge_count {
|
||||
if self.edges[edge_idx].deleted {
|
||||
continue;
|
||||
}
|
||||
let edge_id = EdgeId(edge_idx as u32);
|
||||
let edge_curve = self.edges[edge_idx].curve;
|
||||
|
||||
let intersections = find_curve_intersections(segment, &edge_curve);
|
||||
|
||||
// Filter and collect valid hits for this edge
|
||||
let mut edge_hits: Vec<(f64, f64, Point)> = intersections
|
||||
.iter()
|
||||
.filter_map(|ix| {
|
||||
let seg_t = ix.t1;
|
||||
let edge_t = ix.t2.unwrap_or(0.5);
|
||||
if seg_t < ENDPOINT_T_MARGIN || seg_t > 1.0 - ENDPOINT_T_MARGIN {
|
||||
return None;
|
||||
}
|
||||
if edge_t < ENDPOINT_T_MARGIN || edge_t > 1.0 - ENDPOINT_T_MARGIN {
|
||||
return None;
|
||||
}
|
||||
Some((seg_t, edge_t, ix.point))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if edge_hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort by edge_t descending — split from the end first so that
|
||||
// earlier t values remain valid on the (shortening) original edge.
|
||||
edge_hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
|
||||
// Track how much of the original edge the current "head" covers.
|
||||
// After splitting at t, the head covers [0, t] of the original,
|
||||
// so the next split at t' < t needs remapping: t' / t.
|
||||
let mut head_end = 1.0_f64;
|
||||
|
||||
for (seg_t, original_edge_t, point) in edge_hits {
|
||||
// Remap to the current head's parameter space
|
||||
let remapped_t = original_edge_t / head_end;
|
||||
let remapped_t = remapped_t.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN);
|
||||
|
||||
let (vertex, new_edge) = self.split_edge(edge_id, remapped_t);
|
||||
|
||||
// Place vertex at the intersection point (shared between both curves)
|
||||
self.vertices[vertex.idx()].position = point;
|
||||
self.snap_edge_endpoints_to_vertex(edge_id, vertex);
|
||||
self.snap_edge_endpoints_to_vertex(new_edge, vertex);
|
||||
|
||||
result.split_edges.push((edge_id, original_edge_t, vertex, new_edge));
|
||||
result.new_vertices.push(vertex);
|
||||
splits.push(SegmentSplit { t: seg_t, vertex });
|
||||
|
||||
// The head edge now covers [0, original_edge_t] of the original
|
||||
head_end = original_edge_t;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by t along the stroke segment
|
||||
splits.sort_by(|a, b| a.t.partial_cmp(&b.t).unwrap());
|
||||
|
||||
// Deduplicate near-identical splits
|
||||
splits.dedup_by(|a, b| (a.t - b.t).abs() < ENDPOINT_T_MARGIN);
|
||||
|
||||
splits
|
||||
}
|
||||
|
||||
/// Insert a multi-segment stroke into the DCEL.
|
||||
///
|
||||
/// For each segment:
|
||||
/// 1. Find intersections with existing edges and split them immediately
|
||||
/// 2. Snap segment start/end to existing vertices or create new ones
|
||||
/// 3. Build a vertex chain: [seg_start, intersection_vertices..., seg_end]
|
||||
/// 4. Insert sub-edges between consecutive chain vertices
|
||||
pub fn insert_stroke(
|
||||
&mut self,
|
||||
segments: &[CubicBez],
|
||||
stroke_style: Option<StrokeStyle>,
|
||||
stroke_color: Option<ShapeColor>,
|
||||
epsilon: f64,
|
||||
) -> InsertStrokeResult {
|
||||
if let Some(ref mut rec) = self.debug_recorder {
|
||||
rec.record_stroke(segments);
|
||||
}
|
||||
|
||||
let mut result = InsertStrokeResult {
|
||||
new_vertices: Vec::new(),
|
||||
new_edges: Vec::new(),
|
||||
split_edges: Vec::new(),
|
||||
new_faces: Vec::new(),
|
||||
};
|
||||
|
||||
if segments.is_empty() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Pre-pass: split any self-intersecting segments into two.
|
||||
// A cubic can self-intersect at most once, producing two sub-segments
|
||||
// that share a vertex at the crossing. This must happen before the
|
||||
// main loop so the second half can intersect the first half's edge.
|
||||
let mut expanded: Vec<CubicBez> = Vec::with_capacity(segments.len());
|
||||
for seg in segments {
|
||||
if let Some((t1, t2, point)) = Self::find_cubic_self_intersection(seg) {
|
||||
// Split into 4 sub-segments: [0,t1], [t1,mid], [mid,t2], [t2,1]
|
||||
// where mid is the midpoint of the loop. This avoids creating
|
||||
// a loop edge (same start and end vertex) which would break
|
||||
// the DCEL topology.
|
||||
let t_mid = (t1 + t2) / 2.0;
|
||||
|
||||
let mut s0 = subsegment_cubic(*seg, 0.0, t1);
|
||||
let mut s1 = subsegment_cubic(*seg, t1, t_mid);
|
||||
let mut s2 = subsegment_cubic(*seg, t_mid, t2);
|
||||
let mut s3 = subsegment_cubic(*seg, t2, 1.0);
|
||||
|
||||
// Snap junctions to the crossing point
|
||||
s0.p3 = point;
|
||||
s1.p0 = point;
|
||||
s2.p3 = point;
|
||||
s3.p0 = point;
|
||||
|
||||
expanded.push(s0);
|
||||
expanded.push(s1);
|
||||
expanded.push(s2);
|
||||
expanded.push(s3);
|
||||
} else {
|
||||
expanded.push(*seg);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each segment: find intersections, split existing edges,
|
||||
// then insert sub-edges for the stroke.
|
||||
//
|
||||
// We track prev_vertex so that adjacent segments share their
|
||||
// junction vertex (the end of segment N is the start of segment N+1).
|
||||
let mut prev_vertex: Option<VertexId> = None;
|
||||
|
||||
for (seg_idx, seg) in expanded.iter().enumerate() {
|
||||
// Phase 1: Intersect this segment against all existing edges
|
||||
let splits = self.intersect_and_split_segment(seg, &mut result);
|
||||
|
||||
// Phase 2: Resolve segment start vertex
|
||||
let seg_start = if let Some(pv) = prev_vertex {
|
||||
pv
|
||||
} else {
|
||||
self.snap_vertex(seg.p0, epsilon)
|
||||
.unwrap_or_else(|| self.alloc_vertex(seg.p0))
|
||||
};
|
||||
|
||||
// Phase 3: Resolve segment end vertex
|
||||
let seg_end = if seg_idx == expanded.len() - 1 {
|
||||
// Last segment: snap end point
|
||||
self.snap_vertex(seg.p3, epsilon)
|
||||
.unwrap_or_else(|| self.alloc_vertex(seg.p3))
|
||||
} else {
|
||||
// Interior joint: snap to the shared endpoint with next segment
|
||||
self.snap_vertex(seg.p3, epsilon)
|
||||
.unwrap_or_else(|| self.alloc_vertex(seg.p3))
|
||||
};
|
||||
|
||||
// Phase 4: Build vertex chain
|
||||
let mut chain: Vec<(f64, VertexId)> = Vec::with_capacity(splits.len() + 2);
|
||||
chain.push((0.0, seg_start));
|
||||
for s in &splits {
|
||||
chain.push((s.t, s.vertex));
|
||||
}
|
||||
chain.push((1.0, seg_end));
|
||||
|
||||
// Remove consecutive duplicates (e.g. if seg_start snapped to a split vertex)
|
||||
chain.dedup_by(|a, b| a.1 == b.1);
|
||||
|
||||
// Phase 5: Insert sub-edges
|
||||
for pair in chain.windows(2) {
|
||||
let (t0, v0) = pair[0];
|
||||
let (t1, v1) = pair[1];
|
||||
if v0 == v1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut sub_curve = subsegment_cubic(*seg, t0, t1);
|
||||
// Snap curve endpoints to exact vertex positions
|
||||
sub_curve.p0 = self.vertices[v0.idx()].position;
|
||||
sub_curve.p3 = self.vertices[v1.idx()].position;
|
||||
|
||||
// Determine face by probing the curve midpoint
|
||||
let mid = sub_curve.eval(0.5);
|
||||
let face = self.find_face_at_point(mid).face;
|
||||
|
||||
let (edge_id, new_face) = self.insert_edge(v0, v1, face, sub_curve);
|
||||
|
||||
self.edges[edge_id.idx()].stroke_style = stroke_style.clone();
|
||||
self.edges[edge_id.idx()].stroke_color = stroke_color;
|
||||
|
||||
result.new_edges.push(edge_id);
|
||||
if new_face != face && new_face.0 != 0 {
|
||||
result.new_faces.push(new_face);
|
||||
}
|
||||
}
|
||||
|
||||
// Track vertices
|
||||
if !result.new_vertices.contains(&seg_start) {
|
||||
result.new_vertices.push(seg_start);
|
||||
}
|
||||
if !result.new_vertices.contains(&seg_end) {
|
||||
result.new_vertices.push(seg_end);
|
||||
}
|
||||
|
||||
prev_vertex = Some(seg_end);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
self.validate();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Find the self-intersection of a cubic bezier, if any.
|
||||
///
|
||||
/// A cubic can self-intersect at most once. Returns Some((t1, t2, point))
|
||||
/// with t1 < t2 if the curve crosses itself, None otherwise.
|
||||
///
|
||||
/// Algebraic approach: B(t) = P0 + 3at + 3bt² + ct³ where
|
||||
/// a = P1-P0, b = P2-2P1+P0, c = P3-3P2+3P1-P0
|
||||
///
|
||||
/// B(t1) = B(t2), factor (t1-t2), let s=t1+t2, p=t1*t2:
|
||||
/// 3a + 3b·s + c·(s²-p) = 0 (two equations, x and y)
|
||||
///
|
||||
/// Cross-product elimination gives s, back-substitution gives p,
|
||||
/// then t1,t2 = (s ± √(s²-4p)) / 2.
|
||||
fn find_cubic_self_intersection(curve: &CubicBez) -> Option<(f64, f64, Point)> {
|
||||
let ax = curve.p1.x - curve.p0.x;
|
||||
let ay = curve.p1.y - curve.p0.y;
|
||||
let bx = curve.p2.x - 2.0 * curve.p1.x + curve.p0.x;
|
||||
let by = curve.p2.y - 2.0 * curve.p1.y + curve.p0.y;
|
||||
let cx = curve.p3.x - 3.0 * curve.p2.x + 3.0 * curve.p1.x - curve.p0.x;
|
||||
let cy = curve.p3.y - 3.0 * curve.p2.y + 3.0 * curve.p1.y - curve.p0.y;
|
||||
|
||||
// s = -(a × c) / (b × c) where × is 2D cross product
|
||||
let b_cross_c = bx * cy - by * cx;
|
||||
if b_cross_c.abs() < 1e-10 {
|
||||
return None; // degenerate — no self-intersection
|
||||
}
|
||||
|
||||
let a_cross_c = ax * cy - ay * cx;
|
||||
let s = -a_cross_c / b_cross_c;
|
||||
|
||||
// Back-substitute to find p. Use whichever component of c is larger
|
||||
// to avoid division by near-zero.
|
||||
let p = if cx.abs() > cy.abs() {
|
||||
// From x: cx*(s²-p) + 3*bx*s + 3*ax = 0
|
||||
// p = s² + (3*bx*s + 3*ax) / cx
|
||||
s * s + (3.0 * bx * s + 3.0 * ax) / cx
|
||||
} else if cy.abs() > 1e-10 {
|
||||
s * s + (3.0 * by * s + 3.0 * ay) / cy
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// t1, t2 = (s ± √(s²-4p)) / 2
|
||||
let disc = s * s - 4.0 * p;
|
||||
if disc < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let sqrt_disc = disc.sqrt();
|
||||
let t1 = (s - sqrt_disc) / 2.0;
|
||||
let t2 = (s + sqrt_disc) / 2.0;
|
||||
|
||||
// Both must be strictly inside (0, 1)
|
||||
if t1 <= ENDPOINT_T_MARGIN || t2 >= 1.0 - ENDPOINT_T_MARGIN || t1 >= t2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let p1 = curve.eval(t1);
|
||||
let p2 = curve.eval(t2);
|
||||
let point = Point::new((p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5);
|
||||
|
||||
Some((t1, t2, point))
|
||||
}
|
||||
|
||||
/// Recompute intersections for a single edge against all other edges.
|
||||
///
|
||||
/// Used after editing a curve's control points — finds new crossings and
|
||||
/// splits both curves at each intersection. Returns the list of
|
||||
/// (new_vertex, new_edge) pairs created by splits.
|
||||
pub fn recompute_edge_intersections(
|
||||
&mut self,
|
||||
edge_id: EdgeId,
|
||||
) -> Vec<(VertexId, EdgeId)> {
|
||||
if self.edges[edge_id.idx()].deleted {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let curve = self.edges[edge_id.idx()].curve;
|
||||
let mut created = Vec::new();
|
||||
|
||||
// 1. Check for self-intersection (loop in this single curve)
|
||||
if let Some((t1, t2, point)) = Self::find_cubic_self_intersection(&curve) {
|
||||
// Split into 4 sub-edges: [0,t1], [t1,mid], [mid,t2], [t2,1]
|
||||
// This avoids creating a self-loop edge (same start and end vertex).
|
||||
let t_mid = (t1 + t2) / 2.0;
|
||||
|
||||
// Create one crossing vertex and one loop midpoint vertex
|
||||
let cv = self.alloc_vertex(point);
|
||||
let mid_point = curve.eval(t_mid);
|
||||
let v_mid = self.alloc_vertex(mid_point);
|
||||
|
||||
// Split high-t to low-t, reusing cv for both crossing points
|
||||
let (_, tail_edge) = self.split_edge_at_vertex(edge_id, t2, cv);
|
||||
created.push((cv, tail_edge));
|
||||
|
||||
let remapped_mid = t_mid / t2;
|
||||
let (_, mid_edge2) = self.split_edge_at_vertex(edge_id, remapped_mid, v_mid);
|
||||
created.push((v_mid, mid_edge2));
|
||||
|
||||
let remapped_t1 = t1 / t_mid;
|
||||
let (_, mid_edge1) = self.split_edge_at_vertex(edge_id, remapped_t1, cv);
|
||||
created.push((cv, mid_edge1));
|
||||
|
||||
// Splits inserted cv twice without maintaining the CCW fan — fix it
|
||||
self.rebuild_vertex_fan(cv);
|
||||
}
|
||||
|
||||
// 2. Check against all other edges
|
||||
let edge_count = self.edges.len();
|
||||
for other_idx in 0..edge_count {
|
||||
if self.edges[other_idx].deleted {
|
||||
continue;
|
||||
}
|
||||
let other_id = EdgeId(other_idx as u32);
|
||||
if other_id == edge_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also skip edges created by splitting edge_id above
|
||||
// (they are pieces of the same curve)
|
||||
if created.iter().any(|&(_, e)| e == other_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let other_curve = self.edges[other_idx].curve;
|
||||
let intersections = find_curve_intersections(&curve, &other_curve);
|
||||
|
||||
let mut hits: Vec<(f64, f64, Point)> = intersections
|
||||
.iter()
|
||||
.filter_map(|ix| {
|
||||
let seg_t = ix.t1;
|
||||
let edge_t = ix.t2.unwrap_or(0.5);
|
||||
if seg_t < ENDPOINT_T_MARGIN || seg_t > 1.0 - ENDPOINT_T_MARGIN {
|
||||
return None;
|
||||
}
|
||||
if edge_t < ENDPOINT_T_MARGIN || edge_t > 1.0 - ENDPOINT_T_MARGIN {
|
||||
return None;
|
||||
}
|
||||
Some((seg_t, edge_t, ix.point))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort by edge_t descending — split from end first
|
||||
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
|
||||
let mut head_end = 1.0_f64;
|
||||
for (_seg_t, original_edge_t, point) in hits {
|
||||
let remapped_t = (original_edge_t / head_end)
|
||||
.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN);
|
||||
|
||||
let (vertex, new_edge) = self.split_edge(other_id, remapped_t);
|
||||
self.vertices[vertex.idx()].position = point;
|
||||
self.snap_edge_endpoints_to_vertex(other_id, vertex);
|
||||
self.snap_edge_endpoints_to_vertex(new_edge, vertex);
|
||||
|
||||
created.push((vertex, new_edge));
|
||||
head_end = original_edge_t;
|
||||
}
|
||||
}
|
||||
|
||||
created
|
||||
}
|
||||
|
||||
/// Ensure that any edge endpoint touching `vertex` has its curve snapped
|
||||
/// to the vertex's exact position.
|
||||
fn snap_edge_endpoints_to_vertex(&mut self, edge_id: EdgeId, vertex: VertexId) {
|
||||
let vpos = self.vertices[vertex.idx()].position;
|
||||
let edge = &self.edges[edge_id.idx()];
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
|
||||
if self.half_edges[fwd.idx()].origin == vertex {
|
||||
self.edges[edge_id.idx()].curve.p0 = vpos;
|
||||
}
|
||||
if self.half_edges[bwd.idx()].origin == vertex {
|
||||
self.edges[edge_id.idx()].curve.p3 = vpos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Point;
|
||||
|
||||
#[test]
|
||||
fn u_and_c_four_intersections() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
let u_curve = CubicBez::new(
|
||||
Point::new(0.0, 100.0),
|
||||
Point::new(0.0, -40.0),
|
||||
Point::new(100.0, -40.0),
|
||||
Point::new(100.0, 100.0),
|
||||
);
|
||||
|
||||
let v1 = dcel.alloc_vertex(u_curve.p0);
|
||||
let v2 = dcel.alloc_vertex(u_curve.p3);
|
||||
dcel.insert_edge(v1, v2, FaceId(0), u_curve);
|
||||
|
||||
let c_curve = CubicBez::new(
|
||||
Point::new(120.0, 80.0),
|
||||
Point::new(-40.0, 80.0),
|
||||
Point::new(-40.0, 20.0),
|
||||
Point::new(120.0, 20.0),
|
||||
);
|
||||
|
||||
let mut result = InsertStrokeResult {
|
||||
new_vertices: Vec::new(),
|
||||
new_edges: Vec::new(),
|
||||
split_edges: Vec::new(),
|
||||
new_faces: Vec::new(),
|
||||
};
|
||||
|
||||
let splits = dcel.intersect_and_split_segment(&c_curve, &mut result);
|
||||
|
||||
println!("Found {} splits:", splits.len());
|
||||
for (i, s) in splits.iter().enumerate() {
|
||||
let pos = dcel.vertices[s.vertex.idx()].position;
|
||||
println!(" {i}: t={:.4} V{} ({:.2}, {:.2})", s.t, s.vertex.0, pos.x, pos.y);
|
||||
}
|
||||
|
||||
assert_eq!(splits.len(), 4, "U and C should cross 4 times");
|
||||
assert_eq!(result.split_edges.len(), 4);
|
||||
|
||||
let split_verts: Vec<VertexId> = splits.iter().map(|s| s.vertex).collect();
|
||||
|
||||
// All split vertices distinct
|
||||
for i in 0..split_verts.len() {
|
||||
for j in (i + 1)..split_verts.len() {
|
||||
assert_ne!(split_verts[i], split_verts[j]);
|
||||
}
|
||||
}
|
||||
|
||||
// t-values ascending along C
|
||||
for w in splits.windows(2) {
|
||||
assert!(w[0].t < w[1].t);
|
||||
}
|
||||
|
||||
// --- Verify U is now 5 edges chained through the 4 split vertices ---
|
||||
// Walk from v1 along forward half-edges to v2.
|
||||
// The original edge (edge 0) was shortened; tails were appended.
|
||||
// Walk: v1 → split_v[highest_edge_t] → ... → split_v[lowest_edge_t] → v2
|
||||
// (splits were high-t-first, so the edge chain from v1 goes through
|
||||
// the lowest-edge_t vertex first)
|
||||
let mut u_chain: Vec<VertexId> = vec![v1];
|
||||
let mut cur_he = dcel.vertices[v1.idx()].outgoing;
|
||||
for _ in 0..10 {
|
||||
let dest = dcel.half_edge_dest(cur_he);
|
||||
u_chain.push(dest);
|
||||
if dest == v2 {
|
||||
break;
|
||||
}
|
||||
// Follow forward: next half-edge in the cycle from dest
|
||||
// For a chain in F0, the forward half-edge's next is the backward
|
||||
// of the same spur, so we need to use the twin's next instead
|
||||
// to walk along the chain.
|
||||
let twin = dcel.half_edges[cur_he.idx()].twin;
|
||||
// At dest, find the outgoing half-edge that continues the chain
|
||||
// (not the one going back the way we came)
|
||||
let outgoing = dcel.vertex_outgoing(dest);
|
||||
let back_he = twin; // the half-edge arriving at dest from our direction
|
||||
// The next edge in the chain is the outgoing that isn't the return
|
||||
cur_he = *outgoing.iter()
|
||||
.find(|&&he| he != dcel.half_edges[back_he.idx()].next
|
||||
|| outgoing.len() == 1)
|
||||
.unwrap_or(&outgoing[0]);
|
||||
// Actually for a simple chain (degree-2 vertices), there are exactly
|
||||
// 2 outgoing half-edges; pick the one that isn't the twin of how we arrived
|
||||
if outgoing.len() == 2 {
|
||||
let arriving_twin = dcel.half_edges[cur_he.idx()].twin;
|
||||
// We want the outgoing that is NOT the reverse of our arrival
|
||||
cur_he = if outgoing[0] == dcel.half_edges[twin.idx()].next {
|
||||
// twin.next is the next outgoing in the fan — that's continuing back
|
||||
// For degree-2: the two outgoing are twin.next of each other
|
||||
// We want the one that is NOT going back toward v1
|
||||
outgoing[1]
|
||||
} else {
|
||||
outgoing[0]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Simpler approach: just verify that all 4 split vertices appear as
|
||||
// endpoints of non-deleted edges, and that v1 and v2 are still endpoints.
|
||||
let mut u_edge_vertices: Vec<VertexId> = Vec::new();
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted { continue; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
let a = dcel.half_edges[fwd.idx()].origin;
|
||||
let b = dcel.half_edges[bwd.idx()].origin;
|
||||
u_edge_vertices.push(a);
|
||||
u_edge_vertices.push(b);
|
||||
}
|
||||
|
||||
// v1 and v2 (U endpoints) should still be edge endpoints
|
||||
assert!(u_edge_vertices.contains(&v1), "v1 should be an edge endpoint");
|
||||
assert!(u_edge_vertices.contains(&v2), "v2 should be an edge endpoint");
|
||||
|
||||
// All 4 split vertices should be edge endpoints (they split the U)
|
||||
for &sv in &split_verts {
|
||||
assert!(u_edge_vertices.contains(&sv),
|
||||
"split vertex V{} should be an edge endpoint", sv.0);
|
||||
}
|
||||
|
||||
// Should have exactly 5 non-deleted edges (original U split into 5)
|
||||
let live_edges: Vec<EdgeId> = dcel.edges.iter().enumerate()
|
||||
.filter(|(_, e)| !e.deleted)
|
||||
.map(|(i, _)| EdgeId(i as u32))
|
||||
.collect();
|
||||
assert_eq!(live_edges.len(), 5, "U should be split into 5 edges");
|
||||
|
||||
// Each split vertex should have degree 2 (connects two edge pieces)
|
||||
for &sv in &split_verts {
|
||||
let out = dcel.vertex_outgoing(sv);
|
||||
assert_eq!(out.len(), 2,
|
||||
"split vertex V{} should have degree 2, got {}", sv.0, out.len());
|
||||
}
|
||||
|
||||
// --- Verify C sub-curves would share the same vertices ---
|
||||
// The C would be split into 5 sub-curves at t-values [0, t0, t1, t2, t3, 1].
|
||||
// Each sub-curve's endpoints should snap to the split vertices.
|
||||
let mut c_t_values: Vec<f64> = vec![0.0];
|
||||
c_t_values.extend(splits.iter().map(|s| s.t));
|
||||
c_t_values.push(1.0);
|
||||
|
||||
for i in 0..5 {
|
||||
let t0 = c_t_values[i];
|
||||
let t1 = c_t_values[i + 1];
|
||||
let sub = subsegment_cubic(c_curve, t0, t1);
|
||||
|
||||
// Start point of sub-curve should match a known vertex
|
||||
if i > 0 {
|
||||
let expected_v = split_verts[i - 1];
|
||||
let expected_pos = dcel.vertices[expected_v.idx()].position;
|
||||
let dist = ((sub.p0.x - expected_pos.x).powi(2)
|
||||
+ (sub.p0.y - expected_pos.y).powi(2)).sqrt();
|
||||
assert!(dist < 2.0,
|
||||
"C sub-curve {i} start ({:.2},{:.2}) should be near V{} ({:.2},{:.2}), dist={:.3}",
|
||||
sub.p0.x, sub.p0.y, expected_v.0, expected_pos.x, expected_pos.y, dist);
|
||||
}
|
||||
|
||||
// End point should match
|
||||
if i < 4 {
|
||||
let expected_v = split_verts[i];
|
||||
let expected_pos = dcel.vertices[expected_v.idx()].position;
|
||||
let dist = ((sub.p3.x - expected_pos.x).powi(2)
|
||||
+ (sub.p3.y - expected_pos.y).powi(2)).sqrt();
|
||||
assert!(dist < 2.0,
|
||||
"C sub-curve {i} end ({:.2},{:.2}) should be near V{} ({:.2},{:.2}), dist={:.3}",
|
||||
sub.p3.x, sub.p3.y, expected_v.0, expected_pos.x, expected_pos.y, dist);
|
||||
}
|
||||
}
|
||||
|
||||
dcel.validate();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_stroke_u_then_c() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Insert U as a stroke
|
||||
let u_curve = CubicBez::new(
|
||||
Point::new(0.0, 100.0),
|
||||
Point::new(0.0, -40.0),
|
||||
Point::new(100.0, -40.0),
|
||||
Point::new(100.0, 100.0),
|
||||
);
|
||||
let u_result = dcel.insert_stroke(&[u_curve], None, None, 0.5);
|
||||
assert_eq!(u_result.new_edges.len(), 1);
|
||||
|
||||
// Insert C as a stroke — should split both curves at 4 intersections
|
||||
let c_curve = CubicBez::new(
|
||||
Point::new(120.0, 80.0),
|
||||
Point::new(-40.0, 80.0),
|
||||
Point::new(-40.0, 20.0),
|
||||
Point::new(120.0, 20.0),
|
||||
);
|
||||
let c_result = dcel.insert_stroke(&[c_curve], None, None, 0.5);
|
||||
|
||||
println!("C stroke: {} new edges, {} split edges, {} new vertices",
|
||||
c_result.new_edges.len(), c_result.split_edges.len(), c_result.new_vertices.len());
|
||||
|
||||
// U was split at 4 points → 4 split_edges
|
||||
assert_eq!(c_result.split_edges.len(), 4);
|
||||
|
||||
// C was inserted as 5 sub-edges (split at the 4 intersection points)
|
||||
assert_eq!(c_result.new_edges.len(), 5);
|
||||
|
||||
// Total live edges: 5 (U pieces) + 5 (C pieces) = 10
|
||||
let live_edges = dcel.edges.iter().filter(|e| !e.deleted).count();
|
||||
assert_eq!(live_edges, 10);
|
||||
|
||||
// The 4 intersection vertices should each have degree 4
|
||||
// (2 from U chain + 2 from C chain)
|
||||
let split_verts: Vec<VertexId> = c_result.split_edges.iter()
|
||||
.map(|&(_, _, v, _)| v)
|
||||
.collect();
|
||||
for &sv in &split_verts {
|
||||
let degree = dcel.vertex_outgoing(sv).len();
|
||||
assert_eq!(degree, 4,
|
||||
"intersection vertex V{} should have degree 4, got {}",
|
||||
sv.0, degree);
|
||||
}
|
||||
|
||||
dcel.validate();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_stroke_simple_cross() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Horizontal line
|
||||
let h = CubicBez::new(
|
||||
Point::new(0.0, 50.0),
|
||||
Point::new(33.0, 50.0),
|
||||
Point::new(66.0, 50.0),
|
||||
Point::new(100.0, 50.0),
|
||||
);
|
||||
dcel.insert_stroke(&[h], None, None, 0.5);
|
||||
|
||||
// Vertical line crossing it
|
||||
let v = CubicBez::new(
|
||||
Point::new(50.0, 0.0),
|
||||
Point::new(50.0, 33.0),
|
||||
Point::new(50.0, 66.0),
|
||||
Point::new(50.0, 100.0),
|
||||
);
|
||||
let result = dcel.insert_stroke(&[v], None, None, 0.5);
|
||||
|
||||
// One intersection
|
||||
assert_eq!(result.split_edges.len(), 1);
|
||||
// Vertical inserted as 2 sub-edges
|
||||
assert_eq!(result.new_edges.len(), 2);
|
||||
// Total: 2 (H pieces) + 2 (V pieces) = 4
|
||||
let live = dcel.edges.iter().filter(|e| !e.deleted).count();
|
||||
assert_eq!(live, 4);
|
||||
|
||||
// Intersection vertex has degree 4
|
||||
let ix_v = result.split_edges[0].2;
|
||||
assert_eq!(dcel.vertex_outgoing(ix_v).len(), 4);
|
||||
|
||||
dcel.validate();
|
||||
}
|
||||
|
||||
/// Multi-segment stroke that loops back and crosses itself:
|
||||
///
|
||||
/// seg0: right →
|
||||
/// seg1: down ↓
|
||||
/// seg2: left ← (crosses seg0)
|
||||
///
|
||||
/// Since segments are inserted sequentially, seg2 should find and split
|
||||
/// the already-inserted seg0 edge at the crossing.
|
||||
#[test]
|
||||
fn insert_stroke_self_crossing_multi_segment() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
let seg0 = CubicBez::new(
|
||||
Point::new(0.0, 50.0),
|
||||
Point::new(33.0, 50.0),
|
||||
Point::new(66.0, 50.0),
|
||||
Point::new(100.0, 50.0),
|
||||
);
|
||||
let seg1 = CubicBez::new(
|
||||
Point::new(100.0, 50.0),
|
||||
Point::new(100.0, 66.0),
|
||||
Point::new(100.0, 83.0),
|
||||
Point::new(100.0, 100.0),
|
||||
);
|
||||
let seg2 = CubicBez::new(
|
||||
Point::new(100.0, 100.0),
|
||||
Point::new(66.0, 100.0),
|
||||
Point::new(33.0, 0.0),
|
||||
Point::new(0.0, 0.0),
|
||||
);
|
||||
|
||||
let result = dcel.insert_stroke(&[seg0, seg1, seg2], None, None, 0.5);
|
||||
|
||||
println!("Self-crossing: {} edges, {} splits, {} vertices",
|
||||
result.new_edges.len(), result.split_edges.len(), result.new_vertices.len());
|
||||
|
||||
// seg2 should cross seg0 once
|
||||
assert_eq!(result.split_edges.len(), 1, "seg2 should cross seg0 once");
|
||||
|
||||
// Crossing vertex should have degree 4
|
||||
let ix_v = result.split_edges[0].2;
|
||||
let degree = dcel.vertex_outgoing(ix_v).len();
|
||||
assert_eq!(degree, 4,
|
||||
"self-crossing vertex should have degree 4, got {}", degree);
|
||||
|
||||
dcel.validate();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_self_intersection_loop() {
|
||||
// Asymmetric control points that form a true loop (not a cusp).
|
||||
// The wider spread gives disc > 0, so t1 ≠ t2.
|
||||
let curve = CubicBez::new(
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(200.0, 100.0),
|
||||
Point::new(-100.0, 100.0),
|
||||
Point::new(100.0, 0.0),
|
||||
);
|
||||
|
||||
let result = Dcel::find_cubic_self_intersection(&curve);
|
||||
assert!(result.is_some(), "curve should self-intersect");
|
||||
|
||||
let (t1, t2, point) = result.unwrap();
|
||||
println!("Self-ix: t1={t1:.4} t2={t2:.4} at ({:.2}, {:.2})", point.x, point.y);
|
||||
assert!(t1 > 0.0 && t1 < 1.0);
|
||||
assert!(t2 > t1 && t2 < 1.0);
|
||||
// Crossing point should be near the middle of the curve
|
||||
assert!((point.x - 50.0).abs() < 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_self_intersection_none_for_simple_curve() {
|
||||
let curve = CubicBez::new(
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(33.0, 0.0),
|
||||
Point::new(66.0, 0.0),
|
||||
Point::new(100.0, 0.0),
|
||||
);
|
||||
assert!(Dcel::find_cubic_self_intersection(&curve).is_none());
|
||||
}
|
||||
|
||||
/// Simulate the editor flow: insert a straight edge, then change its
|
||||
/// curve to a self-intersecting loop, then call recompute_edge_intersections.
|
||||
/// The crossing vertex should have degree 4 (4 edges meeting there).
|
||||
#[test]
|
||||
fn recompute_self_intersecting_edge() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Insert a straight edge
|
||||
let p0 = Point::new(0.0, 0.0);
|
||||
let p1 = Point::new(100.0, 0.0);
|
||||
let v0 = dcel.alloc_vertex(p0);
|
||||
let v1 = dcel.alloc_vertex(p1);
|
||||
let straight = CubicBez::new(p0, Point::new(33.0, 0.0), Point::new(66.0, 0.0), p1);
|
||||
let (edge_id, _) = dcel.insert_edge(v0, v1, FaceId(0), straight);
|
||||
|
||||
assert_eq!(dcel.edges.iter().filter(|e| !e.deleted).count(), 1);
|
||||
|
||||
// Mutate the curve to be self-intersecting (like the user dragging control points)
|
||||
dcel.edges[edge_id.idx()].curve = CubicBez::new(
|
||||
p0,
|
||||
Point::new(200.0, 100.0),
|
||||
Point::new(-100.0, 100.0),
|
||||
p1,
|
||||
);
|
||||
|
||||
// Recompute — should detect self-intersection and split
|
||||
let created = dcel.recompute_edge_intersections(edge_id);
|
||||
println!("recompute created {} splits", created.len());
|
||||
|
||||
// Should have 4 live edges: [0,t1], [t1,mid], [mid,t2], [t2,1]
|
||||
let live_edges = dcel.edges.iter().filter(|e| !e.deleted).count();
|
||||
println!("live edges: {live_edges}");
|
||||
assert_eq!(live_edges, 4, "self-intersecting curve should become 4 edges");
|
||||
|
||||
// Find the crossing vertex: it's the one with degree 4
|
||||
let mut crossing_vertex = None;
|
||||
for (i, v) in dcel.vertices.iter().enumerate() {
|
||||
if v.deleted || v.outgoing.is_none() { continue; }
|
||||
let vid = super::super::VertexId(i as u32);
|
||||
let degree = dcel.vertex_outgoing(vid).len();
|
||||
println!("V{i}: degree={degree} pos=({:.1},{:.1})", v.position.x, v.position.y);
|
||||
if degree == 4 {
|
||||
crossing_vertex = Some(vid);
|
||||
}
|
||||
}
|
||||
|
||||
let cv = crossing_vertex.expect("should have a degree-4 crossing vertex");
|
||||
|
||||
// All 4 outgoing half-edges should belong to different edges
|
||||
let outgoing = dcel.vertex_outgoing(cv);
|
||||
assert_eq!(outgoing.len(), 4, "crossing vertex should have degree 4");
|
||||
|
||||
let mut edge_ids: Vec<EdgeId> = outgoing
|
||||
.iter()
|
||||
.map(|&he| dcel.half_edges[he.idx()].edge)
|
||||
.collect();
|
||||
edge_ids.sort_by_key(|e| e.0);
|
||||
edge_ids.dedup();
|
||||
assert_eq!(edge_ids.len(), 4, "all 4 outgoing should be on different edges");
|
||||
|
||||
// Verify all 4 edges have the crossing vertex as an endpoint
|
||||
for &eid in &edge_ids {
|
||||
let [fwd, bwd] = dcel.edges[eid.idx()].half_edges;
|
||||
let va = dcel.half_edges[fwd.idx()].origin;
|
||||
let vb = dcel.half_edges[bwd.idx()].origin;
|
||||
assert!(
|
||||
va == cv || vb == cv,
|
||||
"edge E{} endpoints V{},V{} should include crossing vertex V{}",
|
||||
eid.0, va.0, vb.0, cv.0
|
||||
);
|
||||
}
|
||||
|
||||
dcel.validate();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_stroke_self_intersecting_segment() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Single segment that loops on itself (same curve as find_self_intersection_loop)
|
||||
let loop_curve = CubicBez::new(
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(200.0, 100.0),
|
||||
Point::new(-100.0, 100.0),
|
||||
Point::new(100.0, 0.0),
|
||||
);
|
||||
|
||||
let result = dcel.insert_stroke(&[loop_curve], None, None, 0.5);
|
||||
|
||||
// Expanded to 4 sub-segments: [0,t1], [t1,mid], [mid,t2], [t2,1]
|
||||
// The loop is split in half to avoid a same-vertex edge.
|
||||
assert_eq!(result.new_edges.len(), 4);
|
||||
|
||||
dcel.validate();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,715 @@
|
|||
//! Pure topology operations on the DCEL.
|
||||
//!
|
||||
//! Core invariants maintained by all operations:
|
||||
//! - Half-edges leaving a vertex are in sorted CCW order by angle.
|
||||
//! The fan is traversed via: `twin(he).next` gives the next CCW outgoing.
|
||||
//! - `he.next` walks CCW around the face to the left of `he`.
|
||||
//! - `he.prev` is the inverse of `next`.
|
||||
//! - `he.twin.origin` is the destination of `he`.
|
||||
//! - Faces are only created when splitting an existing non-F0 face.
|
||||
|
||||
use super::{
|
||||
subdivide_cubic, Dcel, EdgeId, FaceId, HalfEdgeId, VertexId, DEFAULT_SNAP_EPSILON,
|
||||
};
|
||||
use kurbo::CubicBez;
|
||||
|
||||
impl Dcel {
|
||||
/// Angle of the curve's forward direction at its start (p0 → p1, fallback p0 → p3).
|
||||
pub fn curve_start_angle(curve: &CubicBez) -> f64 {
|
||||
let dx = curve.p1.x - curve.p0.x;
|
||||
let dy = curve.p1.y - curve.p0.y;
|
||||
if dx * dx + dy * dy > 1e-18 {
|
||||
dy.atan2(dx)
|
||||
} else {
|
||||
(curve.p3.y - curve.p0.y).atan2(curve.p3.x - curve.p0.x)
|
||||
}
|
||||
}
|
||||
|
||||
/// Angle of the curve's backward direction at its end (p3 → p2, fallback p3 → p0).
|
||||
pub fn curve_end_angle(curve: &CubicBez) -> f64 {
|
||||
let dx = curve.p2.x - curve.p3.x;
|
||||
let dy = curve.p2.y - curve.p3.y;
|
||||
if dx * dx + dy * dy > 1e-18 {
|
||||
dy.atan2(dx)
|
||||
} else {
|
||||
(curve.p0.y - curve.p3.y).atan2(curve.p0.x - curve.p3.x)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outgoing angle of a half-edge at its origin vertex.
|
||||
pub fn outgoing_angle(&self, he: HalfEdgeId) -> f64 {
|
||||
let edge = &self.edges[self.half_edges[he.idx()].edge.idx()];
|
||||
if he == edge.half_edges[0] {
|
||||
Self::curve_start_angle(&edge.curve)
|
||||
} else {
|
||||
Self::curve_end_angle(&edge.curve)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the existing outgoing half-edge from `vertex` that is the immediate
|
||||
/// CCW successor of `angle` in the vertex fan.
|
||||
///
|
||||
/// Returns the half-edge whose angular position is the smallest CCW rotation
|
||||
/// from `angle`. This is where a new edge at `angle` should be spliced before.
|
||||
fn find_ccw_successor(&self, vertex: VertexId, angle: f64) -> HalfEdgeId {
|
||||
let start = self.vertices[vertex.idx()].outgoing;
|
||||
debug_assert!(!start.is_none(), "find_ccw_successor on isolated vertex");
|
||||
|
||||
let mut best = start;
|
||||
let mut best_delta = f64::MAX;
|
||||
let mut cur = start;
|
||||
loop {
|
||||
let a = self.outgoing_angle(cur);
|
||||
let mut delta = a - angle;
|
||||
if delta <= 0.0 {
|
||||
delta += std::f64::consts::TAU;
|
||||
}
|
||||
if delta < best_delta {
|
||||
best_delta = delta;
|
||||
best = cur;
|
||||
}
|
||||
let twin = self.half_edges[cur.idx()].twin;
|
||||
cur = self.half_edges[twin.idx()].next;
|
||||
if cur == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Insert an edge between two existing vertices.
|
||||
///
|
||||
/// `face` is the face that both vertices lie on (for the (true, true) case,
|
||||
/// the face is determined by the angular sector and `face` is ignored).
|
||||
///
|
||||
/// Returns `(edge_id, face_id)` where `face_id` is:
|
||||
/// - A new face if the edge split an existing non-F0 face
|
||||
/// - The face the edge was inserted into otherwise
|
||||
///
|
||||
/// # Face creation rules
|
||||
/// - Faces are only created when both vertices are on the same boundary cycle
|
||||
/// of a face that is NOT face 0 (the unbounded face). This is the "split" case.
|
||||
/// - Creating a closed cycle in face 0 does NOT auto-create a face.
|
||||
/// - The cycle containing the old face's `outer_half_edge` keeps the old face.
|
||||
/// The other cycle gets a new face with inherited fill data.
|
||||
pub fn insert_edge(
|
||||
&mut self,
|
||||
v1: VertexId,
|
||||
v2: VertexId,
|
||||
face: FaceId,
|
||||
curve: CubicBez,
|
||||
) -> (EdgeId, FaceId) {
|
||||
debug_assert!(v1 != v2, "cannot insert self-loop");
|
||||
|
||||
let v1_isolated = self.vertices[v1.idx()].outgoing.is_none();
|
||||
let v2_isolated = self.vertices[v2.idx()].outgoing.is_none();
|
||||
|
||||
// Allocate edge + half-edge pair
|
||||
let (he_fwd, he_bwd) = self.alloc_half_edge_pair();
|
||||
let edge_id = self.alloc_edge(curve);
|
||||
|
||||
self.edges[edge_id.idx()].half_edges = [he_fwd, he_bwd];
|
||||
self.half_edges[he_fwd.idx()].edge = edge_id;
|
||||
self.half_edges[he_bwd.idx()].edge = edge_id;
|
||||
self.half_edges[he_fwd.idx()].origin = v1;
|
||||
self.half_edges[he_bwd.idx()].origin = v2;
|
||||
|
||||
match (v1_isolated, v2_isolated) {
|
||||
(true, true) => {
|
||||
self.insert_edge_both_isolated(he_fwd, he_bwd, v1, v2, edge_id, face)
|
||||
}
|
||||
(false, false) => {
|
||||
self.insert_edge_both_connected(he_fwd, he_bwd, v1, v2, edge_id, &curve)
|
||||
}
|
||||
_ => {
|
||||
self.insert_edge_one_isolated(he_fwd, he_bwd, v1, v2, edge_id, &curve, v1_isolated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Both vertices isolated: first edge, no face split possible.
|
||||
fn insert_edge_both_isolated(
|
||||
&mut self,
|
||||
he_fwd: HalfEdgeId,
|
||||
he_bwd: HalfEdgeId,
|
||||
v1: VertexId,
|
||||
v2: VertexId,
|
||||
edge_id: EdgeId,
|
||||
face: FaceId,
|
||||
) -> (EdgeId, FaceId) {
|
||||
// Two half-edges form a trivial 2-cycle
|
||||
self.half_edges[he_fwd.idx()].next = he_bwd;
|
||||
self.half_edges[he_fwd.idx()].prev = he_bwd;
|
||||
self.half_edges[he_bwd.idx()].next = he_fwd;
|
||||
self.half_edges[he_bwd.idx()].prev = he_fwd;
|
||||
|
||||
self.half_edges[he_fwd.idx()].face = face;
|
||||
self.half_edges[he_bwd.idx()].face = face;
|
||||
|
||||
// Register with face
|
||||
if face.0 == 0 {
|
||||
self.faces[0].inner_half_edges.push(he_fwd);
|
||||
} else if self.faces[face.idx()].outer_half_edge.is_none() {
|
||||
self.faces[face.idx()].outer_half_edge = he_fwd;
|
||||
}
|
||||
|
||||
self.vertices[v1.idx()].outgoing = he_fwd;
|
||||
self.vertices[v2.idx()].outgoing = he_bwd;
|
||||
|
||||
(edge_id, face)
|
||||
}
|
||||
|
||||
/// One vertex isolated, one connected: spur/antenna edge, no face split.
|
||||
fn insert_edge_one_isolated(
|
||||
&mut self,
|
||||
he_fwd: HalfEdgeId,
|
||||
he_bwd: HalfEdgeId,
|
||||
v1: VertexId,
|
||||
v2: VertexId,
|
||||
edge_id: EdgeId,
|
||||
curve: &CubicBez,
|
||||
v1_is_isolated: bool,
|
||||
) -> (EdgeId, FaceId) {
|
||||
let (connected, isolated) = if v1_is_isolated { (v2, v1) } else { (v1, v2) };
|
||||
|
||||
// Determine which half-edge goes OUT from connected vertex
|
||||
let (he_out, he_back) = if self.half_edges[he_fwd.idx()].origin == connected {
|
||||
(he_fwd, he_bwd)
|
||||
} else {
|
||||
(he_bwd, he_fwd)
|
||||
};
|
||||
|
||||
// Find where to splice in the fan at the connected vertex
|
||||
let out_angle = if self.half_edges[he_fwd.idx()].origin == connected {
|
||||
Self::curve_start_angle(curve)
|
||||
} else {
|
||||
Self::curve_end_angle(curve)
|
||||
};
|
||||
let ccw_succ = self.find_ccw_successor(connected, out_angle);
|
||||
let he_into = self.half_edges[ccw_succ.idx()].prev;
|
||||
let actual_face = self.half_edges[he_into.idx()].face;
|
||||
|
||||
// Splice: ... → he_into → [he_out → he_back] → ccw_succ → ...
|
||||
self.half_edges[he_into.idx()].next = he_out;
|
||||
self.half_edges[he_out.idx()].prev = he_into;
|
||||
self.half_edges[he_out.idx()].next = he_back;
|
||||
self.half_edges[he_back.idx()].prev = he_out;
|
||||
self.half_edges[he_back.idx()].next = ccw_succ;
|
||||
self.half_edges[ccw_succ.idx()].prev = he_back;
|
||||
|
||||
self.half_edges[he_out.idx()].face = actual_face;
|
||||
self.half_edges[he_back.idx()].face = actual_face;
|
||||
|
||||
self.vertices[isolated.idx()].outgoing = he_back;
|
||||
|
||||
(edge_id, actual_face)
|
||||
}
|
||||
|
||||
/// Both vertices connected: may split a face.
|
||||
fn insert_edge_both_connected(
|
||||
&mut self,
|
||||
he_fwd: HalfEdgeId,
|
||||
he_bwd: HalfEdgeId,
|
||||
v1: VertexId,
|
||||
v2: VertexId,
|
||||
edge_id: EdgeId,
|
||||
curve: &CubicBez,
|
||||
) -> (EdgeId, FaceId) {
|
||||
let fwd_angle = Self::curve_start_angle(curve);
|
||||
let bwd_angle = Self::curve_end_angle(curve);
|
||||
|
||||
let ccw_v1 = self.find_ccw_successor(v1, fwd_angle);
|
||||
let ccw_v2 = self.find_ccw_successor(v2, bwd_angle);
|
||||
|
||||
let into_v1 = self.half_edges[ccw_v1.idx()].prev;
|
||||
let into_v2 = self.half_edges[ccw_v2.idx()].prev;
|
||||
|
||||
let actual_face = self.half_edges[into_v1.idx()].face;
|
||||
|
||||
// Splice:
|
||||
// into_v1 → he_fwd → ccw_v2 → ...
|
||||
// into_v2 → he_bwd → ccw_v1 → ...
|
||||
self.half_edges[he_fwd.idx()].prev = into_v1;
|
||||
self.half_edges[he_fwd.idx()].next = ccw_v2;
|
||||
self.half_edges[into_v1.idx()].next = he_fwd;
|
||||
self.half_edges[ccw_v2.idx()].prev = he_fwd;
|
||||
|
||||
self.half_edges[he_bwd.idx()].prev = into_v2;
|
||||
self.half_edges[he_bwd.idx()].next = ccw_v1;
|
||||
self.half_edges[into_v2.idx()].next = he_bwd;
|
||||
self.half_edges[ccw_v1.idx()].prev = he_bwd;
|
||||
|
||||
// Detect split vs bridge: walk from he_fwd. If we return to he_fwd
|
||||
// without seeing he_bwd, they are on separate cycles → split.
|
||||
let is_split = !self.cycle_contains(he_fwd, he_bwd);
|
||||
|
||||
if !is_split {
|
||||
// Bridge: merged two cycles into one. All on actual_face.
|
||||
self.assign_cycle_face(he_fwd, actual_face);
|
||||
if actual_face.0 != 0 {
|
||||
self.faces[actual_face.idx()].outer_half_edge = he_fwd;
|
||||
}
|
||||
return (edge_id, actual_face);
|
||||
}
|
||||
|
||||
// Split case: two separate cycles.
|
||||
// Only create a new face if the face being split is not F0.
|
||||
if actual_face.0 == 0 {
|
||||
// In the unbounded face, just assign both cycles to F0.
|
||||
self.half_edges[he_fwd.idx()].face = FaceId(0);
|
||||
self.assign_cycle_face(he_fwd, FaceId(0));
|
||||
self.assign_cycle_face(he_bwd, FaceId(0));
|
||||
return (edge_id, FaceId(0));
|
||||
}
|
||||
|
||||
// Determine which cycle keeps the old face: the one containing
|
||||
// the old face's outer_half_edge.
|
||||
let old_ohe = self.faces[actual_face.idx()].outer_half_edge;
|
||||
let fwd_has_old = !old_ohe.is_none() && self.cycle_contains(he_fwd, old_ohe);
|
||||
|
||||
let (he_old_cycle, he_new_cycle) = if fwd_has_old {
|
||||
(he_fwd, he_bwd)
|
||||
} else {
|
||||
(he_bwd, he_fwd)
|
||||
};
|
||||
|
||||
// Old cycle keeps actual_face
|
||||
self.assign_cycle_face(he_old_cycle, actual_face);
|
||||
self.faces[actual_face.idx()].outer_half_edge = he_old_cycle;
|
||||
|
||||
// New cycle gets a new face with inherited fill data
|
||||
let new_face = self.alloc_face();
|
||||
self.faces[new_face.idx()].fill_color = self.faces[actual_face.idx()].fill_color;
|
||||
self.faces[new_face.idx()].image_fill = self.faces[actual_face.idx()].image_fill;
|
||||
self.faces[new_face.idx()].fill_rule = self.faces[actual_face.idx()].fill_rule;
|
||||
self.faces[new_face.idx()].outer_half_edge = he_new_cycle;
|
||||
self.assign_cycle_face(he_new_cycle, new_face);
|
||||
|
||||
(edge_id, new_face)
|
||||
}
|
||||
|
||||
/// Check if walking the cycle from `start` encounters `target`.
|
||||
fn cycle_contains(&self, start: HalfEdgeId, target: HalfEdgeId) -> bool {
|
||||
let mut cur = self.half_edges[start.idx()].next;
|
||||
let mut steps = 0;
|
||||
while cur != start {
|
||||
if cur == target {
|
||||
return true;
|
||||
}
|
||||
cur = self.half_edges[cur.idx()].next;
|
||||
steps += 1;
|
||||
debug_assert!(steps < 100_000, "infinite cycle in cycle_contains");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Set the face of every half-edge in the cycle starting at `start`.
|
||||
fn assign_cycle_face(&mut self, start: HalfEdgeId, face: FaceId) {
|
||||
self.half_edges[start.idx()].face = face;
|
||||
let mut cur = self.half_edges[start.idx()].next;
|
||||
let mut steps = 0;
|
||||
while cur != start {
|
||||
self.half_edges[cur.idx()].face = face;
|
||||
cur = self.half_edges[cur.idx()].next;
|
||||
steps += 1;
|
||||
debug_assert!(steps < 100_000, "infinite cycle in assign_cycle_face");
|
||||
}
|
||||
}
|
||||
|
||||
/// Split an edge at parameter `t`, inserting a new vertex.
|
||||
///
|
||||
/// The original edge is shortened to [0, t]. A new edge covers [t, 1].
|
||||
/// Stroke style is copied to the new edge.
|
||||
/// Returns `(new_vertex, new_edge)`.
|
||||
pub fn split_edge(&mut self, edge_id: EdgeId, t: f64) -> (VertexId, EdgeId) {
|
||||
let original_curve = self.edges[edge_id.idx()].curve;
|
||||
let (curve_a, _) = subdivide_cubic(original_curve, t);
|
||||
let split_point = curve_a.p3;
|
||||
let vertex = self
|
||||
.snap_vertex(split_point, DEFAULT_SNAP_EPSILON)
|
||||
.unwrap_or_else(|| self.alloc_vertex(split_point));
|
||||
self.split_edge_at_vertex(edge_id, t, vertex)
|
||||
}
|
||||
|
||||
/// Split an edge at parameter `t`, using a specific pre-existing vertex.
|
||||
///
|
||||
/// The original edge is shortened to [0, t]. A new edge covers [t, 1].
|
||||
/// Curve endpoints are snapped to the vertex position.
|
||||
/// Returns `(vertex, new_edge)`.
|
||||
pub fn split_edge_at_vertex(
|
||||
&mut self,
|
||||
edge_id: EdgeId,
|
||||
t: f64,
|
||||
vertex: VertexId,
|
||||
) -> (VertexId, EdgeId) {
|
||||
debug_assert!((0.0..=1.0).contains(&t), "t out of range");
|
||||
|
||||
let original_curve = self.edges[edge_id.idx()].curve;
|
||||
let (mut curve_a, mut curve_b) = subdivide_cubic(original_curve, t);
|
||||
|
||||
let vpos = self.vertices[vertex.idx()].position;
|
||||
curve_a.p3 = vpos;
|
||||
curve_b.p0 = vpos;
|
||||
|
||||
let [he_fwd, he_bwd] = self.edges[edge_id.idx()].half_edges;
|
||||
|
||||
// Allocate new edge + half-edge pair for second segment
|
||||
let (new_he_fwd, new_he_bwd) = self.alloc_half_edge_pair();
|
||||
let new_edge_id = self.alloc_edge(curve_b);
|
||||
|
||||
self.edges[new_edge_id.idx()].half_edges = [new_he_fwd, new_he_bwd];
|
||||
self.half_edges[new_he_fwd.idx()].edge = new_edge_id;
|
||||
self.half_edges[new_he_bwd.idx()].edge = new_edge_id;
|
||||
|
||||
// Copy stroke style
|
||||
self.edges[new_edge_id.idx()].stroke_style =
|
||||
self.edges[edge_id.idx()].stroke_style.clone();
|
||||
self.edges[new_edge_id.idx()].stroke_color = self.edges[edge_id.idx()].stroke_color;
|
||||
|
||||
// Shorten original edge
|
||||
self.edges[edge_id.idx()].curve = curve_a;
|
||||
|
||||
// Set origins: new_he_fwd goes from vertex onward,
|
||||
// new_he_bwd goes from old destination toward vertex
|
||||
self.half_edges[new_he_fwd.idx()].origin = vertex;
|
||||
let old_dest = self.half_edges[he_bwd.idx()].origin;
|
||||
self.half_edges[new_he_bwd.idx()].origin = old_dest;
|
||||
|
||||
// Splice new_he_fwd into forward cycle:
|
||||
// Before: ... → he_fwd → fwd_next → ...
|
||||
// After: ... → he_fwd → new_he_fwd → fwd_next → ...
|
||||
let fwd_next = self.half_edges[he_fwd.idx()].next;
|
||||
self.half_edges[he_fwd.idx()].next = new_he_fwd;
|
||||
self.half_edges[new_he_fwd.idx()].prev = he_fwd;
|
||||
self.half_edges[new_he_fwd.idx()].next = fwd_next;
|
||||
self.half_edges[fwd_next.idx()].prev = new_he_fwd;
|
||||
self.half_edges[new_he_fwd.idx()].face = self.half_edges[he_fwd.idx()].face;
|
||||
|
||||
// Splice new_he_bwd into backward cycle:
|
||||
// Before: ... → bwd_prev → he_bwd → ...
|
||||
// After: ... → bwd_prev → new_he_bwd → he_bwd → ...
|
||||
let bwd_prev = self.half_edges[he_bwd.idx()].prev;
|
||||
self.half_edges[bwd_prev.idx()].next = new_he_bwd;
|
||||
self.half_edges[new_he_bwd.idx()].prev = bwd_prev;
|
||||
self.half_edges[new_he_bwd.idx()].next = he_bwd;
|
||||
self.half_edges[he_bwd.idx()].prev = new_he_bwd;
|
||||
self.half_edges[new_he_bwd.idx()].face = self.half_edges[he_bwd.idx()].face;
|
||||
|
||||
// he_bwd now originates from vertex (it covers [vertex → v1])
|
||||
self.half_edges[he_bwd.idx()].origin = vertex;
|
||||
|
||||
// Fix old destination's outgoing if it pointed at he_bwd
|
||||
if self.vertices[old_dest.idx()].outgoing == he_bwd {
|
||||
self.vertices[old_dest.idx()].outgoing = new_he_bwd;
|
||||
}
|
||||
|
||||
// Set vertex's outgoing (may already have one if vertex is shared)
|
||||
if self.vertices[vertex.idx()].outgoing.is_none() {
|
||||
self.vertices[vertex.idx()].outgoing = new_he_fwd;
|
||||
}
|
||||
|
||||
(vertex, new_edge_id)
|
||||
}
|
||||
|
||||
/// Remove an edge, merging its two adjacent faces.
|
||||
/// Returns the surviving face (lower ID, always keeps face 0).
|
||||
pub fn remove_edge(&mut self, edge_id: EdgeId) -> FaceId {
|
||||
let [he_fwd, he_bwd] = self.edges[edge_id.idx()].half_edges;
|
||||
let face_a = self.half_edges[he_fwd.idx()].face;
|
||||
let face_b = self.half_edges[he_bwd.idx()].face;
|
||||
|
||||
let (surviving, dying) = if face_a.0 <= face_b.0 {
|
||||
(face_a, face_b)
|
||||
} else {
|
||||
(face_b, face_a)
|
||||
};
|
||||
|
||||
let fwd_prev = self.half_edges[he_fwd.idx()].prev;
|
||||
let fwd_next = self.half_edges[he_fwd.idx()].next;
|
||||
let bwd_prev = self.half_edges[he_bwd.idx()].prev;
|
||||
let bwd_next = self.half_edges[he_bwd.idx()].next;
|
||||
|
||||
let v1 = self.half_edges[he_fwd.idx()].origin;
|
||||
let v2 = self.half_edges[he_bwd.idx()].origin;
|
||||
|
||||
// Splice out half-edges. Four cases based on adjacency.
|
||||
if fwd_next == he_bwd && bwd_next == he_fwd {
|
||||
// Degenerate 2-cycle: both vertices become isolated
|
||||
self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE;
|
||||
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
||||
} else if fwd_next == he_bwd {
|
||||
// Spur: he_fwd → he_bwd consecutive. Remove both.
|
||||
self.half_edges[fwd_prev.idx()].next = bwd_next;
|
||||
self.half_edges[bwd_next.idx()].prev = fwd_prev;
|
||||
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
||||
if self.vertices[v1.idx()].outgoing == he_fwd {
|
||||
self.vertices[v1.idx()].outgoing = bwd_next;
|
||||
}
|
||||
} else if bwd_next == he_fwd {
|
||||
// Spur: he_bwd → he_fwd consecutive. Remove both.
|
||||
self.half_edges[bwd_prev.idx()].next = fwd_next;
|
||||
self.half_edges[fwd_next.idx()].prev = bwd_prev;
|
||||
self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE;
|
||||
if self.vertices[v2.idx()].outgoing == he_bwd {
|
||||
self.vertices[v2.idx()].outgoing = fwd_next;
|
||||
}
|
||||
} else {
|
||||
// Normal: splice out both half-edges
|
||||
self.half_edges[fwd_prev.idx()].next = bwd_next;
|
||||
self.half_edges[bwd_next.idx()].prev = fwd_prev;
|
||||
self.half_edges[bwd_prev.idx()].next = fwd_next;
|
||||
self.half_edges[fwd_next.idx()].prev = bwd_prev;
|
||||
|
||||
if self.vertices[v1.idx()].outgoing == he_fwd {
|
||||
self.vertices[v1.idx()].outgoing = bwd_next;
|
||||
}
|
||||
if self.vertices[v2.idx()].outgoing == he_bwd {
|
||||
self.vertices[v2.idx()].outgoing = fwd_next;
|
||||
}
|
||||
}
|
||||
|
||||
// Reassign dying face's half-edges to surviving face
|
||||
if surviving != dying && !dying.is_none() {
|
||||
let walk_start = self.find_surviving_he_for_face(dying, he_fwd, he_bwd, fwd_next, bwd_next);
|
||||
if !walk_start.is_none() {
|
||||
self.assign_cycle_face(walk_start, surviving);
|
||||
}
|
||||
// Merge holes
|
||||
let inner = std::mem::take(&mut self.faces[dying.idx()].inner_half_edges);
|
||||
self.faces[surviving.idx()].inner_half_edges.extend(inner);
|
||||
}
|
||||
|
||||
// Fix surviving face's outer_half_edge
|
||||
if self.faces[surviving.idx()].outer_half_edge == he_fwd
|
||||
|| self.faces[surviving.idx()].outer_half_edge == he_bwd
|
||||
{
|
||||
let replacement = [fwd_next, bwd_next]
|
||||
.into_iter()
|
||||
.find(|&he| he != he_fwd && he != he_bwd && !self.half_edges[he.idx()].deleted)
|
||||
.unwrap_or(HalfEdgeId::NONE);
|
||||
self.faces[surviving.idx()].outer_half_edge = replacement;
|
||||
}
|
||||
|
||||
// Clean up inner_half_edges references
|
||||
self.faces[surviving.idx()]
|
||||
.inner_half_edges
|
||||
.retain(|&he| he != he_fwd && he != he_bwd);
|
||||
|
||||
// Free
|
||||
self.free_half_edge(he_fwd);
|
||||
self.free_half_edge(he_bwd);
|
||||
self.free_edge(edge_id);
|
||||
if surviving != dying && !dying.is_none() && dying.0 != 0 {
|
||||
self.free_face(dying);
|
||||
}
|
||||
|
||||
surviving
|
||||
}
|
||||
|
||||
/// Find a valid starting half-edge for walking a dying face's cycle,
|
||||
/// avoiding the two half-edges being removed.
|
||||
fn find_surviving_he_for_face(
|
||||
&self,
|
||||
dying: FaceId,
|
||||
he_fwd: HalfEdgeId,
|
||||
he_bwd: HalfEdgeId,
|
||||
fwd_next: HalfEdgeId,
|
||||
bwd_next: HalfEdgeId,
|
||||
) -> HalfEdgeId {
|
||||
let ohe = self.faces[dying.idx()].outer_half_edge;
|
||||
if !ohe.is_none() && ohe != he_fwd && ohe != he_bwd {
|
||||
return ohe;
|
||||
}
|
||||
for &candidate in &[fwd_next, bwd_next] {
|
||||
if !candidate.is_none() && candidate != he_fwd && candidate != he_bwd {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
HalfEdgeId::NONE
|
||||
}
|
||||
|
||||
/// Re-sort all outgoing half-edges at a vertex by angle and fix the
|
||||
/// fan linkage (`twin.next` / `prev`). Call this after operations that
|
||||
/// add outgoing half-edges to an existing vertex without maintaining
|
||||
/// the CCW fan invariant (e.g. multiple `split_edge_at_vertex` calls
|
||||
/// reusing the same vertex).
|
||||
pub fn rebuild_vertex_fan(&mut self, vertex: VertexId) {
|
||||
let start = self.vertices[vertex.idx()].outgoing;
|
||||
if start.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all outgoing half-edges by walking all connected sub-fans.
|
||||
// The fan may be broken into disconnected loops, so we gather them
|
||||
// by scanning all half-edges with origin == vertex.
|
||||
let mut fan: Vec<(f64, HalfEdgeId)> = Vec::new();
|
||||
for (i, he) in self.half_edges.iter().enumerate() {
|
||||
if he.deleted {
|
||||
continue;
|
||||
}
|
||||
if he.origin == vertex {
|
||||
let he_id = HalfEdgeId(i as u32);
|
||||
let angle = self.outgoing_angle(he_id);
|
||||
fan.push((angle, he_id));
|
||||
}
|
||||
}
|
||||
|
||||
if fan.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by angle CCW
|
||||
fan.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Relink: twin(fan[i]).next = fan[(i+1) % n]
|
||||
let n = fan.len();
|
||||
for i in 0..n {
|
||||
let cur_he = fan[i].1;
|
||||
let next_he = fan[(i + 1) % n].1;
|
||||
let cur_twin = self.half_edges[cur_he.idx()].twin;
|
||||
self.half_edges[cur_twin.idx()].next = next_he;
|
||||
self.half_edges[next_he.idx()].prev = cur_twin;
|
||||
}
|
||||
|
||||
self.vertices[vertex.idx()].outgoing = fan[0].1;
|
||||
}
|
||||
|
||||
/// Merge vertex `v_remove` into `v_keep`. Both must be at the same position
|
||||
/// (or close enough). All half-edges originating from `v_remove` are re-homed
|
||||
/// to `v_keep`, and the combined fan is re-sorted by angle.
|
||||
pub fn merge_vertices(&mut self, v_keep: VertexId, v_remove: VertexId) {
|
||||
if v_keep == v_remove {
|
||||
return;
|
||||
}
|
||||
debug_assert!(!self.vertices[v_keep.idx()].outgoing.is_none());
|
||||
debug_assert!(!self.vertices[v_remove.idx()].outgoing.is_none());
|
||||
|
||||
// Re-home all half-edges from v_remove to v_keep
|
||||
let start = self.vertices[v_remove.idx()].outgoing;
|
||||
let mut cur = start;
|
||||
loop {
|
||||
self.half_edges[cur.idx()].origin = v_keep;
|
||||
let twin = self.half_edges[cur.idx()].twin;
|
||||
cur = self.half_edges[twin.idx()].next;
|
||||
if cur == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
self.vertices[v_remove.idx()].outgoing = HalfEdgeId::NONE;
|
||||
self.vertices[v_remove.idx()].deleted = true;
|
||||
self.free_vertices.push(v_remove.0);
|
||||
|
||||
// Rebuild the combined fan at v_keep
|
||||
self.rebuild_vertex_fan(v_keep);
|
||||
self.vertex_rtree = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Point;
|
||||
|
||||
fn line_curve(p0: Point, p1: Point) -> CubicBez {
|
||||
let c1 = super::super::lerp_point(p0, p1, 1.0 / 3.0);
|
||||
let c2 = super::super::lerp_point(p0, p1, 2.0 / 3.0);
|
||||
CubicBez::new(p0, c1, c2, p1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_single_edge() {
|
||||
let mut dcel = Dcel::new();
|
||||
let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
||||
let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0));
|
||||
let curve = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0));
|
||||
|
||||
let (edge_id, face) = dcel.insert_edge(v1, v2, FaceId(0), curve);
|
||||
assert!(!edge_id.is_none());
|
||||
assert_eq!(face, FaceId(0));
|
||||
|
||||
// Both half-edges should form a 2-cycle
|
||||
let [he_fwd, he_bwd] = dcel.edges[edge_id.idx()].half_edges;
|
||||
assert_eq!(dcel.half_edges[he_fwd.idx()].next, he_bwd);
|
||||
assert_eq!(dcel.half_edges[he_bwd.idx()].next, he_fwd);
|
||||
assert_eq!(dcel.half_edges[he_fwd.idx()].origin, v1);
|
||||
assert_eq!(dcel.half_edges[he_bwd.idx()].origin, v2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_spur() {
|
||||
let mut dcel = Dcel::new();
|
||||
let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
||||
let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0));
|
||||
let v3 = dcel.alloc_vertex(Point::new(10.0, 10.0));
|
||||
|
||||
let c1 = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0));
|
||||
let c2 = line_curve(Point::new(10.0, 0.0), Point::new(10.0, 10.0));
|
||||
|
||||
dcel.insert_edge(v1, v2, FaceId(0), c1);
|
||||
let (e2, _) = dcel.insert_edge(v2, v3, FaceId(0), c2);
|
||||
|
||||
// v3 should have outgoing pointing back toward v2
|
||||
let v3_out = dcel.vertices[v3.idx()].outgoing;
|
||||
assert!(!v3_out.is_none());
|
||||
assert_eq!(dcel.half_edges[v3_out.idx()].origin, v3);
|
||||
|
||||
// Edge should exist
|
||||
assert!(!e2.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_triangle_no_face_in_f0() {
|
||||
let mut dcel = Dcel::new();
|
||||
let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
||||
let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0));
|
||||
let v3 = dcel.alloc_vertex(Point::new(5.0, 10.0));
|
||||
|
||||
let c1 = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0));
|
||||
let c2 = line_curve(Point::new(10.0, 0.0), Point::new(5.0, 10.0));
|
||||
let c3 = line_curve(Point::new(5.0, 10.0), Point::new(0.0, 0.0));
|
||||
|
||||
dcel.insert_edge(v1, v2, FaceId(0), c1);
|
||||
dcel.insert_edge(v2, v3, FaceId(0), c2);
|
||||
let (_e3, face) = dcel.insert_edge(v3, v1, FaceId(0), c3);
|
||||
|
||||
// In F0, closing a triangle should NOT create a new face
|
||||
assert_eq!(face, FaceId(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_edge_creates_vertex() {
|
||||
let mut dcel = Dcel::new();
|
||||
let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
||||
let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0));
|
||||
let curve = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0));
|
||||
|
||||
let (edge_id, _) = dcel.insert_edge(v1, v2, FaceId(0), curve);
|
||||
let (new_v, new_e) = dcel.split_edge(edge_id, 0.5);
|
||||
|
||||
// New vertex should be near (5, 0)
|
||||
let pos = dcel.vertices[new_v.idx()].position;
|
||||
assert!((pos.x - 5.0).abs() < 0.1);
|
||||
assert!((pos.y - 0.0).abs() < 0.1);
|
||||
|
||||
// Should now have 2 edges
|
||||
assert!(!new_e.is_none());
|
||||
assert_ne!(edge_id, new_e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_edge_basic() {
|
||||
let mut dcel = Dcel::new();
|
||||
let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
||||
let v2 = dcel.alloc_vertex(Point::new(10.0, 0.0));
|
||||
let curve = line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0));
|
||||
|
||||
let (edge_id, _) = dcel.insert_edge(v1, v2, FaceId(0), curve);
|
||||
let surviving = dcel.remove_edge(edge_id);
|
||||
|
||||
assert_eq!(surviving, FaceId(0));
|
||||
assert!(dcel.vertices[v1.idx()].outgoing.is_none());
|
||||
assert!(dcel.vertices[v2.idx()].outgoing.is_none());
|
||||
assert!(dcel.edges[edge_id.idx()].deleted);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ use crate::layer::VectorLayer;
|
|||
use crate::shape::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
|
||||
use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape};
|
||||
|
||||
/// Result of a hit test operation
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -216,40 +216,6 @@ pub fn hit_test_dcel_in_rect(
|
|||
result
|
||||
}
|
||||
|
||||
/// Classification of shapes relative to a clipping region
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShapeRegionClassification {
|
||||
/// Shapes entirely inside the region
|
||||
pub fully_inside: Vec<Uuid>,
|
||||
/// Shapes whose paths cross the region boundary
|
||||
pub intersecting: Vec<Uuid>,
|
||||
/// Shapes with no overlap with the region
|
||||
pub fully_outside: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// Classify shapes in a layer relative to a clipping region.
|
||||
///
|
||||
/// Uses bounding box fast-rejection, then checks path-region intersection
|
||||
/// and containment for accurate classification.
|
||||
pub fn classify_shapes_by_region(
|
||||
layer: &VectorLayer,
|
||||
time: f64,
|
||||
region: &BezPath,
|
||||
parent_transform: Affine,
|
||||
) -> ShapeRegionClassification {
|
||||
let result = ShapeRegionClassification {
|
||||
fully_inside: Vec::new(),
|
||||
intersecting: Vec::new(),
|
||||
fully_outside: Vec::new(),
|
||||
};
|
||||
|
||||
let region_bbox = region.bounding_box();
|
||||
|
||||
// TODO: Implement DCEL-based region classification
|
||||
let _ = (layer, time, parent_transform, region_bbox);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get the bounding box of a shape in screen space
|
||||
pub fn get_shape_bounds(
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ pub mod file_io;
|
|||
pub mod export;
|
||||
pub mod clipboard;
|
||||
pub mod region_select;
|
||||
pub mod dcel;
|
||||
pub mod dcel2;
|
||||
pub use dcel2 as dcel;
|
||||
pub mod snap;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ struct Crossing {
|
|||
// ── Core clipping ────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert a line segment to a CubicBez
|
||||
fn line_to_cubic(line: &Line) -> CubicBez {
|
||||
pub fn line_to_cubic(line: &Line) -> CubicBez {
|
||||
let p0 = line.p0;
|
||||
let p1 = line.p1;
|
||||
let cp1 = Point::new(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::dcel::{Dcel, EdgeId, FaceId, VertexId};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::BezPath;
|
||||
use vello::kurbo::{Affine, BezPath};
|
||||
|
||||
/// Selection state for the editor
|
||||
///
|
||||
|
|
@ -271,9 +271,11 @@ impl Selection {
|
|||
|
||||
/// Represents a temporary region-based selection.
|
||||
///
|
||||
/// When a region select is active, elements that cross the region boundary
|
||||
/// are tracked. If the user performs an operation, the selection is
|
||||
/// committed; if they deselect, the original state is restored.
|
||||
/// When a region select is active, the region boundary is inserted into the
|
||||
/// DCEL as invisible edges, splitting existing geometry. Faces inside the
|
||||
/// region are added to the normal `Selection`. If the user performs an
|
||||
/// operation, the selection is committed; if they deselect, the DCEL is
|
||||
/// restored from the snapshot.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegionSelection {
|
||||
/// The clipping region as a closed BezPath (polygon or rect)
|
||||
|
|
@ -282,10 +284,12 @@ pub struct RegionSelection {
|
|||
pub layer_id: Uuid,
|
||||
/// Keyframe time
|
||||
pub time: f64,
|
||||
/// Per-shape split results (legacy, kept for compatibility)
|
||||
pub splits: Vec<()>,
|
||||
/// IDs that were fully inside the region
|
||||
pub fully_inside_ids: Vec<Uuid>,
|
||||
/// Snapshot of the DCEL before region boundary insertion, for revert
|
||||
pub dcel_snapshot: Dcel,
|
||||
/// The extracted DCEL containing geometry inside the region
|
||||
pub selected_dcel: Dcel,
|
||||
/// Transform applied to the selected DCEL (e.g. from dragging)
|
||||
pub transform: Affine,
|
||||
/// Whether the selection has been committed (via an operation on the selection)
|
||||
pub committed: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -824,6 +824,19 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
);
|
||||
}
|
||||
|
||||
// Render selected DCEL from active region selection (with transform)
|
||||
if let Some(ref region_sel) = self.ctx.region_selection {
|
||||
let sel_transform = camera_transform * region_sel.transform;
|
||||
lightningbeam_core::renderer::render_dcel(
|
||||
®ion_sel.selected_dcel,
|
||||
&mut scene,
|
||||
sel_transform,
|
||||
1.0,
|
||||
&self.ctx.document,
|
||||
&mut image_cache,
|
||||
);
|
||||
}
|
||||
|
||||
drop(image_cache);
|
||||
scene
|
||||
};
|
||||
|
|
@ -1007,6 +1020,50 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
}
|
||||
}
|
||||
|
||||
// 1a. Draw stipple overlay on region-selected DCEL
|
||||
if let Some(ref region_sel) = self.ctx.region_selection {
|
||||
use lightningbeam_core::dcel::FaceId as DcelFaceId;
|
||||
let sel_dcel = ®ion_sel.selected_dcel;
|
||||
let sel_transform = overlay_transform * region_sel.transform;
|
||||
let stipple_brush = selection_stipple_brush();
|
||||
let inv_zoom = 1.0 / self.ctx.zoom as f64;
|
||||
let brush_xform = Some(Affine::scale(inv_zoom));
|
||||
|
||||
// Stipple faces with visible fill
|
||||
for (i, face) in sel_dcel.faces.iter().enumerate() {
|
||||
if face.deleted || i == 0 { continue; }
|
||||
if face.fill_color.is_none() && face.image_fill.is_none() { continue; }
|
||||
let face_id = DcelFaceId(i as u32);
|
||||
let path = sel_dcel.face_to_bezpath_with_holes(face_id);
|
||||
scene.fill(
|
||||
vello::peniko::Fill::NonZero,
|
||||
sel_transform,
|
||||
stipple_brush,
|
||||
brush_xform,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
|
||||
// Stipple edges with visible stroke
|
||||
for edge in &sel_dcel.edges {
|
||||
if edge.deleted { continue; }
|
||||
if edge.stroke_style.is_none() && edge.stroke_color.is_none() { continue; }
|
||||
let width = edge.stroke_style.as_ref()
|
||||
.map(|s| s.width)
|
||||
.unwrap_or(2.0);
|
||||
let mut path = vello::kurbo::BezPath::new();
|
||||
path.move_to(edge.curve.p0);
|
||||
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
|
||||
scene.stroke(
|
||||
&vello::kurbo::Stroke::new(width),
|
||||
sel_transform,
|
||||
stipple_brush,
|
||||
brush_xform,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1b. Draw stipple hover highlight on the curve under the mouse
|
||||
// During active curve editing, lock highlight to the edited curve
|
||||
if matches!(self.ctx.selected_tool, Tool::Select | Tool::BezierEdit) {
|
||||
|
|
@ -3676,59 +3733,107 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Execute region selection: classify shapes, clip intersecting ones, create temporary split
|
||||
/// Execute region selection: snapshot DCEL, insert region boundary, extract inside geometry
|
||||
fn execute_region_select(
|
||||
shared: &mut SharedPaneState,
|
||||
region_path: vello::kurbo::BezPath,
|
||||
layer_id: uuid::Uuid,
|
||||
) {
|
||||
use lightningbeam_core::hit_test;
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use vello::kurbo::Affine;
|
||||
use lightningbeam_core::region_select::line_to_cubic;
|
||||
use vello::kurbo::Line;
|
||||
|
||||
let time = *shared.playback_time;
|
||||
|
||||
// Classify shapes
|
||||
let classification = {
|
||||
let document = shared.action_executor.document();
|
||||
let layer = match document.get_layer(&layer_id) {
|
||||
Some(l) => l,
|
||||
// Get mutable DCEL and snapshot it before insertion
|
||||
let document = shared.action_executor.document_mut();
|
||||
let dcel = match document.get_layer_mut(&layer_id) {
|
||||
Some(AnyLayer::Vector(vl)) => match vl.dcel_at_time_mut(time) {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
hit_test::classify_shapes_by_region(vector_layer, time, ®ion_path, Affine::IDENTITY)
|
||||
|
||||
let snapshot = dcel.clone();
|
||||
|
||||
// Convert region path line segments to CubicBez for insert_stroke
|
||||
let segments: Vec<_> = {
|
||||
let mut segs = Vec::new();
|
||||
let mut current = vello::kurbo::Point::ZERO;
|
||||
let mut subpath_start = vello::kurbo::Point::ZERO;
|
||||
for el in region_path.elements() {
|
||||
match *el {
|
||||
vello::kurbo::PathEl::MoveTo(p) => {
|
||||
current = p;
|
||||
subpath_start = p;
|
||||
}
|
||||
vello::kurbo::PathEl::LineTo(p) => {
|
||||
segs.push(line_to_cubic(&Line::new(current, p)));
|
||||
current = p;
|
||||
}
|
||||
vello::kurbo::PathEl::ClosePath => {
|
||||
if current.distance(subpath_start) > 1e-10 {
|
||||
segs.push(line_to_cubic(&Line::new(current, subpath_start)));
|
||||
}
|
||||
current = subpath_start;
|
||||
}
|
||||
vello::kurbo::PathEl::CurveTo(p1, p2, p3) => {
|
||||
segs.push(vello::kurbo::CubicBez::new(current, p1, p2, p3));
|
||||
current = p3;
|
||||
}
|
||||
vello::kurbo::PathEl::QuadTo(_p1, p2) => {
|
||||
segs.push(line_to_cubic(&Line::new(current, p2)));
|
||||
current = p2;
|
||||
}
|
||||
}
|
||||
}
|
||||
segs
|
||||
};
|
||||
|
||||
// If nothing is inside or intersecting, do nothing
|
||||
if classification.fully_inside.is_empty() && classification.intersecting.is_empty() {
|
||||
if segments.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert region boundary as invisible edges (no stroke style/color)
|
||||
let stroke_result = dcel.insert_stroke(&segments, None, None, 1.0);
|
||||
let boundary_verts = stroke_result.new_vertices;
|
||||
|
||||
// Extract the inside portion; self (dcel) keeps the outside + boundary.
|
||||
let mut selected_dcel = dcel.extract_region(®ion_path, &boundary_verts);
|
||||
|
||||
// Propagate fills ONLY on the extracted DCEL. The remainder (dcel) already
|
||||
// has correct fills from the original data — its filled faces (e.g., the
|
||||
// L-shaped remainder) keep their fill, and merged faces from edge removal
|
||||
// correctly have no fill. Running propagate_fills on the remainder would
|
||||
// incorrectly add fill to merged faces that span filled and unfilled areas.
|
||||
selected_dcel.propagate_fills(&snapshot, ®ion_path, &boundary_verts);
|
||||
|
||||
// Check if the extracted DCEL has any visible content
|
||||
let has_visible = selected_dcel.edges.iter().any(|e| !e.deleted && (e.stroke_style.is_some() || e.stroke_color.is_some()))
|
||||
|| selected_dcel.faces.iter().enumerate().any(|(i, f)| !f.deleted && i > 0 && (f.fill_color.is_some() || f.image_fill.is_some()));
|
||||
|
||||
if !has_visible {
|
||||
// Nothing visible inside — restore snapshot and bail
|
||||
*dcel = snapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
shared.selection.clear();
|
||||
|
||||
// TODO: DCEL - region selection element selection deferred to Phase 2
|
||||
|
||||
// For intersecting shapes: compute clip and create temporary splits
|
||||
let splits = Vec::new();
|
||||
|
||||
// TODO: DCEL - region selection shape splitting disabled during migration
|
||||
// (was: get_shape_in_keyframe for intersecting shapes, clip paths, add/remove_shape_from_keyframe)
|
||||
|
||||
// Store region selection state
|
||||
// Store region selection state with extracted DCEL
|
||||
*shared.region_selection = Some(lightningbeam_core::selection::RegionSelection {
|
||||
region_path,
|
||||
layer_id,
|
||||
time,
|
||||
splits,
|
||||
fully_inside_ids: classification.fully_inside,
|
||||
dcel_snapshot: snapshot,
|
||||
selected_dcel,
|
||||
transform: vello::kurbo::Affine::IDENTITY,
|
||||
committed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Revert an uncommitted region selection, restoring original shapes
|
||||
/// Revert an uncommitted region selection, restoring the DCEL from snapshot
|
||||
fn revert_region_selection_static(shared: &mut SharedPaneState) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
|
||||
|
|
@ -3742,21 +3847,15 @@ impl StagePane {
|
|||
return;
|
||||
}
|
||||
|
||||
// Restore the DCEL from the snapshot taken before boundary insertion
|
||||
let doc = shared.action_executor.document_mut();
|
||||
let layer = match doc.get_layer_mut(®ion_sel.layer_id) {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return,
|
||||
};
|
||||
if let Some(AnyLayer::Vector(vl)) = doc.get_layer_mut(®ion_sel.layer_id) {
|
||||
if let Some(dcel) = vl.dcel_at_time_mut(region_sel.time) {
|
||||
*dcel = region_sel.dcel_snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: DCEL - region selection revert disabled during migration
|
||||
// (was: remove_shape_from_keyframe for splits, add_shape_to_keyframe to restore originals)
|
||||
let _ = vector_layer;
|
||||
|
||||
shared.selection.clear();
|
||||
shared.selection.clear_dcel_selection();
|
||||
}
|
||||
|
||||
/// Create a rectangle path centered at origin (easier for curve editing later)
|
||||
|
|
|
|||
Loading…
Reference in New Issue