rewrite dcel

This commit is contained in:
Skyler Lehmkuhl 2026-02-26 18:48:21 -05:00
parent 1621602f41
commit dc27cf253d
11 changed files with 3340 additions and 87 deletions

View File

@ -5,7 +5,7 @@
//! maintained such that wherever two strokes intersect there is a vertex. //! maintained such that wherever two strokes intersect there is a vertex.
use crate::shape::{FillRule, ShapeColor, StrokeStyle}; 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 rstar::{PointDistance, RTree, RTreeObject, AABB};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
@ -774,6 +774,274 @@ impl Dcel {
path 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) // Validation (debug)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -4664,4 +4932,85 @@ mod tests {
dump_all_faces(&d, "After stroke 4"); 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(&region_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(&region_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"
);
}
}
} }

View File

@ -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
}

View File

@ -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);
}
}
}

View File

@ -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(&region, &[]);
// 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(&region, &[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");
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -9,7 +9,7 @@ use crate::layer::VectorLayer;
use crate::shape::Shape; use crate::shape::Shape;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; 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 /// Result of a hit test operation
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -216,40 +216,6 @@ pub fn hit_test_dcel_in_rect(
result 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 /// Get the bounding box of a shape in screen space
pub fn get_shape_bounds( pub fn get_shape_bounds(

View File

@ -44,7 +44,8 @@ pub mod file_io;
pub mod export; pub mod export;
pub mod clipboard; pub mod clipboard;
pub mod region_select; pub mod region_select;
pub mod dcel; pub mod dcel2;
pub use dcel2 as dcel;
pub mod snap; pub mod snap;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]

View File

@ -287,7 +287,7 @@ struct Crossing {
// ── Core clipping ──────────────────────────────────────────────────────── // ── Core clipping ────────────────────────────────────────────────────────
/// Convert a line segment to a CubicBez /// 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 p0 = line.p0;
let p1 = line.p1; let p1 = line.p1;
let cp1 = Point::new( let cp1 = Point::new(

View File

@ -6,7 +6,7 @@ use crate::dcel::{Dcel, EdgeId, FaceId, VertexId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::BezPath; use vello::kurbo::{Affine, BezPath};
/// Selection state for the editor /// Selection state for the editor
/// ///
@ -271,9 +271,11 @@ impl Selection {
/// Represents a temporary region-based selection. /// Represents a temporary region-based selection.
/// ///
/// When a region select is active, elements that cross the region boundary /// When a region select is active, the region boundary is inserted into the
/// are tracked. If the user performs an operation, the selection is /// DCEL as invisible edges, splitting existing geometry. Faces inside the
/// committed; if they deselect, the original state is restored. /// 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)] #[derive(Clone, Debug)]
pub struct RegionSelection { pub struct RegionSelection {
/// The clipping region as a closed BezPath (polygon or rect) /// The clipping region as a closed BezPath (polygon or rect)
@ -282,10 +284,12 @@ pub struct RegionSelection {
pub layer_id: Uuid, pub layer_id: Uuid,
/// Keyframe time /// Keyframe time
pub time: f64, pub time: f64,
/// Per-shape split results (legacy, kept for compatibility) /// Snapshot of the DCEL before region boundary insertion, for revert
pub splits: Vec<()>, pub dcel_snapshot: Dcel,
/// IDs that were fully inside the region /// The extracted DCEL containing geometry inside the region
pub fully_inside_ids: Vec<Uuid>, 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) /// Whether the selection has been committed (via an operation on the selection)
pub committed: bool, pub committed: bool,
} }

View File

@ -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(
&region_sel.selected_dcel,
&mut scene,
sel_transform,
1.0,
&self.ctx.document,
&mut image_cache,
);
}
drop(image_cache); drop(image_cache);
scene 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 = &region_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 // 1b. Draw stipple hover highlight on the curve under the mouse
// During active curve editing, lock highlight to the edited curve // During active curve editing, lock highlight to the edited curve
if matches!(self.ctx.selected_tool, Tool::Select | Tool::BezierEdit) { 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( fn execute_region_select(
shared: &mut SharedPaneState, shared: &mut SharedPaneState,
region_path: vello::kurbo::BezPath, region_path: vello::kurbo::BezPath,
layer_id: uuid::Uuid, layer_id: uuid::Uuid,
) { ) {
use lightningbeam_core::hit_test;
use lightningbeam_core::layer::AnyLayer; 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; let time = *shared.playback_time;
// Classify shapes // Get mutable DCEL and snapshot it before insertion
let classification = { let document = shared.action_executor.document_mut();
let document = shared.action_executor.document(); let dcel = match document.get_layer_mut(&layer_id) {
let layer = match document.get_layer(&layer_id) { Some(AnyLayer::Vector(vl)) => match vl.dcel_at_time_mut(time) {
Some(l) => l, Some(d) => d,
None => return, None => return,
}; },
let vector_layer = match layer { _ => return,
AnyLayer::Vector(vl) => vl,
_ => return,
};
hit_test::classify_shapes_by_region(vector_layer, time, &region_path, Affine::IDENTITY)
}; };
// If nothing is inside or intersecting, do nothing let snapshot = dcel.clone();
if classification.fully_inside.is_empty() && classification.intersecting.is_empty() {
// 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 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(&region_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, &region_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; return;
} }
shared.selection.clear(); shared.selection.clear();
// TODO: DCEL - region selection element selection deferred to Phase 2 // Store region selection state with extracted DCEL
// 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
*shared.region_selection = Some(lightningbeam_core::selection::RegionSelection { *shared.region_selection = Some(lightningbeam_core::selection::RegionSelection {
region_path, region_path,
layer_id, layer_id,
time, time,
splits, dcel_snapshot: snapshot,
fully_inside_ids: classification.fully_inside, selected_dcel,
transform: vello::kurbo::Affine::IDENTITY,
committed: false, 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) { fn revert_region_selection_static(shared: &mut SharedPaneState) {
use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::layer::AnyLayer;
@ -3742,21 +3847,15 @@ impl StagePane {
return; return;
} }
// Restore the DCEL from the snapshot taken before boundary insertion
let doc = shared.action_executor.document_mut(); let doc = shared.action_executor.document_mut();
let layer = match doc.get_layer_mut(&region_sel.layer_id) { if let Some(AnyLayer::Vector(vl)) = doc.get_layer_mut(&region_sel.layer_id) {
Some(l) => l, if let Some(dcel) = vl.dcel_at_time_mut(region_sel.time) {
None => return, *dcel = region_sel.dcel_snapshot;
}; }
let vector_layer = match layer { }
AnyLayer::Vector(vl) => vl,
_ => return,
};
// TODO: DCEL - region selection revert disabled during migration shared.selection.clear_dcel_selection();
// (was: remove_shape_from_keyframe for splits, add_shape_to_keyframe to restore originals)
let _ = vector_layer;
shared.selection.clear();
} }
/// Create a rectangle path centered at origin (easier for curve editing later) /// Create a rectangle path centered at origin (easier for curve editing later)