5017 lines
209 KiB
Rust
5017 lines
209 KiB
Rust
//! 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.
|
|
|
|
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
|
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Shape as KurboShape};
|
|
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 {
|
|
/// Position in document coordinate space.
|
|
pub position: Point,
|
|
/// One outgoing half-edge from this vertex (any one; used to start iteration).
|
|
pub outgoing: HalfEdgeId,
|
|
/// Tombstone flag for free-list reuse.
|
|
#[serde(default)]
|
|
pub deleted: bool,
|
|
}
|
|
|
|
/// A half-edge in the DCEL.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct HalfEdge {
|
|
/// Origin vertex of this half-edge.
|
|
pub origin: VertexId,
|
|
/// Twin (opposite direction) half-edge.
|
|
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,
|
|
/// Tombstone flag for free-list reuse.
|
|
#[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 for this edge: [forward, backward].
|
|
/// Forward half-edge goes from curve.p0 to curve.p3.
|
|
pub half_edges: [HalfEdgeId; 2],
|
|
/// Cubic bezier curve. p0 matches origin of half_edges[0],
|
|
/// p3 matches origin of half_edges[1].
|
|
pub curve: CubicBez,
|
|
/// Stroke style (None = no visible stroke).
|
|
pub stroke_style: Option<StrokeStyle>,
|
|
/// Stroke color (None = no visible stroke).
|
|
pub stroke_color: Option<ShapeColor>,
|
|
/// Tombstone flag for free-list reuse.
|
|
#[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 (walk via `next` to traverse).
|
|
/// NONE for the unbounded face (face 0), which has no outer boundary.
|
|
pub outer_half_edge: HalfEdgeId,
|
|
/// Half-edges on inner boundary cycles (holes).
|
|
pub inner_half_edges: Vec<HalfEdgeId>,
|
|
/// Fill color (None = transparent).
|
|
pub fill_color: Option<ShapeColor>,
|
|
/// Image fill (references ImageAsset by UUID).
|
|
pub image_fill: Option<uuid::Uuid>,
|
|
/// Fill rule.
|
|
pub fill_rule: FillRule,
|
|
/// Tombstone flag for free-list reuse.
|
|
#[serde(default)]
|
|
pub deleted: bool,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spatial index
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// R-tree entry for vertex snap queries.
|
|
#[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
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DCEL container
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Default snap epsilon in document coordinate units.
|
|
pub const DEFAULT_SNAP_EPSILON: f64 = 0.5;
|
|
|
|
/// Doubly-Connected Edge List for a single keyframe's vector artwork.
|
|
#[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>,
|
|
|
|
/// Transient spatial index — rebuilt on load, not serialized.
|
|
#[serde(skip)]
|
|
vertex_rtree: Option<RTree<VertexEntry>>,
|
|
|
|
/// Debug recorder: captures strokes and paint bucket clicks for test generation.
|
|
/// Enable with `dcel.set_recording(true)`.
|
|
#[serde(skip)]
|
|
pub debug_recorder: Option<DebugRecorder>,
|
|
}
|
|
|
|
/// Records DCEL operations for test case generation.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct DebugRecorder {
|
|
pub strokes: Vec<Vec<CubicBez>>,
|
|
pub paint_points: Vec<Point>,
|
|
}
|
|
|
|
impl DebugRecorder {
|
|
/// Record a stroke (called from insert_stroke).
|
|
pub fn record_stroke(&mut self, segments: &[CubicBez]) {
|
|
self.strokes.push(segments.to_vec());
|
|
}
|
|
|
|
/// Record a paint bucket click (called from find_face_containing_point).
|
|
pub fn record_paint(&mut self, point: Point) {
|
|
self.paint_points.push(point);
|
|
}
|
|
|
|
/// Dump a Rust test function to stderr that reproduces the recorded operations.
|
|
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!();
|
|
}
|
|
|
|
if !self.paint_points.is_empty() {
|
|
eprintln!(" // Each paint point should hit a bounded face, and no two should share a face");
|
|
eprintln!(" let paint_points = vec![");
|
|
for pt in &self.paint_points {
|
|
eprintln!(" Point::new({:.1}, {:.1}),", pt.x, pt.y);
|
|
}
|
|
eprintln!(" ];");
|
|
eprintln!(" let mut seen_faces = std::collections::HashSet::new();");
|
|
eprintln!(" for (i, &pt) in paint_points.iter().enumerate() {{");
|
|
eprintln!(" let face = dcel.find_face_containing_point(pt);");
|
|
eprintln!(" eprintln!(\"paint point {{i}} at ({{:.1}}, {{:.1}}) → face {{:?}}\", pt.x, pt.y, face);");
|
|
eprintln!(" assert!(");
|
|
eprintln!(" face.0 != 0,");
|
|
eprintln!(" \"paint point {{i}} at ({{:.1}}, {{:.1}}) hit unbounded face\",");
|
|
eprintln!(" pt.x, pt.y,");
|
|
eprintln!(" );");
|
|
eprintln!(" assert!(");
|
|
eprintln!(" seen_faces.insert(face),");
|
|
eprintln!(" \"paint point {{i}} at ({{:.1}}, {{:.1}}) hit face {{:?}} which was already painted\",");
|
|
eprintln!(" pt.x, pt.y, face,");
|
|
eprintln!(" );");
|
|
eprintln!(" }}");
|
|
}
|
|
|
|
eprintln!(" }}");
|
|
}
|
|
|
|
/// Dump the test to stderr and clear the recorder for the next test.
|
|
pub fn dump_and_reset(&mut self, name: &str) {
|
|
self.dump_test(name);
|
|
self.strokes.clear();
|
|
self.paint_points.clear();
|
|
}
|
|
}
|
|
|
|
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() {
|
|
eprintln!("[DCEL_RECORD] Recording enabled for new DCEL");
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// Enable or disable debug recording at runtime.
|
|
pub fn set_recording(&mut self, enabled: bool) {
|
|
if enabled {
|
|
self.debug_recorder.get_or_insert_with(DebugRecorder::default);
|
|
} else {
|
|
self.debug_recorder = None;
|
|
}
|
|
}
|
|
|
|
/// Returns true if debug recording is active.
|
|
pub fn is_recording(&self) -> bool {
|
|
self.debug_recorder.is_some()
|
|
}
|
|
|
|
/// Dump the recorded test and reset the recorder.
|
|
/// Does nothing if recording is not active.
|
|
pub fn dump_recorded_test(&mut self, name: &str) {
|
|
if let Some(ref mut rec) = self.debug_recorder {
|
|
rec.dump_and_reset(name);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Allocation
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Allocate a new vertex at the given position.
|
|
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
|
|
};
|
|
// Invalidate spatial index
|
|
self.vertex_rtree = None;
|
|
id
|
|
}
|
|
|
|
/// Allocate a half-edge pair (always allocated in pairs). Returns (he_a, he_b).
|
|
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);
|
|
// Wire twins
|
|
self.half_edges[a.idx()].twin = b;
|
|
self.half_edges[b.idx()].twin = a;
|
|
(a, b)
|
|
}
|
|
|
|
/// Allocate an edge. Returns the EdgeId.
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Allocate a face. Returns the FaceId.
|
|
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()]
|
|
}
|
|
|
|
/// Get the destination vertex of a half-edge (i.e., the 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
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Spatial index
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Rebuild the R-tree from current (non-deleted) vertices.
|
|
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));
|
|
}
|
|
|
|
/// Ensure the spatial index is built.
|
|
pub fn ensure_spatial_index(&mut self) {
|
|
if self.vertex_rtree.is_none() {
|
|
self.rebuild_spatial_index();
|
|
}
|
|
}
|
|
|
|
/// Find a vertex within `epsilon` distance of `point`, or None.
|
|
pub fn snap_vertex(&mut self, point: Point, epsilon: f64) -> Option<VertexId> {
|
|
self.ensure_spatial_index();
|
|
let rtree = self.vertex_rtree.as_ref().unwrap();
|
|
let query = [point.x, point.y];
|
|
let nearest = rtree.nearest_neighbor(&query)?;
|
|
let dist_sq = nearest.distance_2(&query);
|
|
if dist_sq <= epsilon * epsilon {
|
|
Some(nearest.id)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Iteration helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Iterate half-edges around a face boundary, starting from `start_he`.
|
|
/// Returns half-edge IDs in order following `next` pointers.
|
|
pub fn face_boundary(&self, face_id: FaceId) -> Vec<HalfEdgeId> {
|
|
let face = self.face(face_id);
|
|
if face.outer_half_edge.is_none() {
|
|
return Vec::new();
|
|
}
|
|
self.walk_cycle(face.outer_half_edge)
|
|
}
|
|
|
|
/// Walk a half-edge cycle starting from `start`, following `next` pointers.
|
|
pub fn walk_cycle(&self, start: HalfEdgeId) -> Vec<HalfEdgeId> {
|
|
let mut result = Vec::new();
|
|
let mut current = start;
|
|
loop {
|
|
result.push(current);
|
|
current = self.half_edge(current).next;
|
|
if current == start {
|
|
break;
|
|
}
|
|
// Safety: prevent infinite loops in corrupted data
|
|
if result.len() > self.half_edges.len() {
|
|
debug_assert!(false, "infinite loop in walk_cycle");
|
|
break;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Iterate all outgoing half-edges from a vertex, sorted CCW by angle.
|
|
/// Returns half-edge IDs where each has `origin == vertex_id`.
|
|
pub fn vertex_outgoing(&self, vertex_id: VertexId) -> Vec<HalfEdgeId> {
|
|
let v = self.vertex(vertex_id);
|
|
if v.outgoing.is_none() {
|
|
return Vec::new();
|
|
}
|
|
// Walk around the vertex: from outgoing, follow twin.next to get
|
|
// the next outgoing half-edge in CCW order.
|
|
let mut result = Vec::new();
|
|
let mut current = v.outgoing;
|
|
loop {
|
|
result.push(current);
|
|
// Go to twin, then next — this gives the next outgoing half-edge CCW
|
|
let twin = self.half_edge(current).twin;
|
|
current = self.half_edge(twin).next;
|
|
if current == v.outgoing {
|
|
break;
|
|
}
|
|
if result.len() > self.half_edges.len() {
|
|
debug_assert!(false, "infinite loop in vertex_outgoing");
|
|
break;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Build a BezPath from a face's outer boundary cycle.
|
|
pub fn face_to_bezpath(&self, face_id: FaceId) -> BezPath {
|
|
let boundary = self.face_boundary(face_id);
|
|
self.cycle_to_bezpath(&boundary)
|
|
}
|
|
|
|
/// Build a BezPath from a half-edge cycle (raw, no spur stripping).
|
|
/// Used for topology operations (winding tests, area comparisons).
|
|
fn cycle_to_bezpath(&self, cycle: &[HalfEdgeId]) -> BezPath {
|
|
self.halfedges_to_bezpath(cycle)
|
|
}
|
|
|
|
/// Build a BezPath with spur edges and vertex-revisit loops stripped.
|
|
///
|
|
/// Spur edges (antennae) appear in the cycle as consecutive pairs that
|
|
/// traverse the same edge in opposite directions. These contribute zero
|
|
/// area but can cause fill rendering artifacts when the path is rasterized.
|
|
///
|
|
/// Vertex-revisit loops occur when a face cycle visits the same vertex
|
|
/// twice (e.g. A→B→C→D→E→C→F). The sub-path between the two visits
|
|
/// (C→D→E→C) is a peninsula that inflates the cycle without enclosing
|
|
/// additional area. We keep the last visit to each vertex and drop
|
|
/// the loop: A→B→C→F.
|
|
fn cycle_to_bezpath_stripped(&self, cycle: &[HalfEdgeId]) -> BezPath {
|
|
let stripped = self.strip_cycle(cycle);
|
|
if stripped.is_empty() {
|
|
return BezPath::new();
|
|
}
|
|
self.halfedges_to_bezpath(&stripped)
|
|
}
|
|
|
|
/// Strip spur edges and vertex-revisit loops from a half-edge cycle.
|
|
///
|
|
/// Returns the simplified list of half-edge IDs.
|
|
fn strip_cycle(&self, cycle: &[HalfEdgeId]) -> Vec<HalfEdgeId> {
|
|
// Pass 1: strip consecutive same-edge spur pairs (stack-based)
|
|
let mut stripped: Vec<HalfEdgeId> = Vec::with_capacity(cycle.len());
|
|
for &he_id in cycle {
|
|
let edge = self.half_edge(he_id).edge;
|
|
if let Some(&top) = stripped.last() {
|
|
if self.half_edge(top).edge == edge {
|
|
stripped.pop();
|
|
continue;
|
|
}
|
|
}
|
|
stripped.push(he_id);
|
|
}
|
|
// Handle wrap-around spur pairs.
|
|
while stripped.len() >= 2 {
|
|
let first_edge = self.half_edge(stripped[0]).edge;
|
|
let last_edge = self.half_edge(*stripped.last().unwrap()).edge;
|
|
if first_edge == last_edge {
|
|
stripped.pop();
|
|
stripped.remove(0);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Pass 2: strip vertex-revisit loops.
|
|
// Walk the stripped cycle. For each half-edge, record the *source*
|
|
// vertex. If we've seen that vertex before, remove the sub-path
|
|
// between the first and current visit (keeping the later path).
|
|
//
|
|
// We repeat until no more revisits are found, since removing one
|
|
// loop can expose another.
|
|
let mut changed = true;
|
|
while changed {
|
|
changed = false;
|
|
let mut result: Vec<HalfEdgeId> = Vec::with_capacity(stripped.len());
|
|
// Map from VertexId → index in `result` where that vertex was last seen as source
|
|
let mut vertex_pos: std::collections::HashMap<VertexId, usize> = std::collections::HashMap::new();
|
|
for &he_id in &stripped {
|
|
let src = self.half_edge_source(he_id);
|
|
if let Some(&prev_pos) = vertex_pos.get(&src) {
|
|
// Vertex revisit! Remove the loop between prev_pos and here.
|
|
// Keep result[0..prev_pos], drop result[prev_pos..], continue from here.
|
|
// Also remove stale vertex_pos entries for dropped half-edges.
|
|
let removed: Vec<HalfEdgeId> = result.drain(prev_pos..).collect();
|
|
for &removed_he in &removed {
|
|
let removed_src = self.half_edge_source(removed_he);
|
|
// Only remove from map if it points to a removed position
|
|
if let Some(&pos) = vertex_pos.get(&removed_src) {
|
|
if pos >= prev_pos {
|
|
vertex_pos.remove(&removed_src);
|
|
}
|
|
}
|
|
}
|
|
changed = true;
|
|
}
|
|
vertex_pos.insert(src, result.len());
|
|
result.push(he_id);
|
|
}
|
|
// Check wrap-around: if the last half-edge's destination == first half-edge's source,
|
|
// that's the expected cycle closure, not a revisit. But if the destination appears
|
|
// as a source of some middle half-edge, we have a wrap-around revisit.
|
|
if !result.is_empty() {
|
|
let last_he = *result.last().unwrap();
|
|
let last_dst = self.half_edge_dest(last_he);
|
|
let first_src = self.half_edge_source(result[0]);
|
|
if last_dst != first_src {
|
|
// The destination of the last edge should match the source of the first
|
|
// for a valid cycle. If not, something is off — don't strip further.
|
|
} else if let Some(&wrap_pos) = vertex_pos.get(&first_src) {
|
|
if wrap_pos > 0 {
|
|
// The cycle start vertex appears mid-cycle. Drop the prefix.
|
|
result.drain(..wrap_pos);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
stripped = result;
|
|
}
|
|
|
|
stripped
|
|
}
|
|
|
|
/// Get the source (origin) vertex of a half-edge.
|
|
#[inline]
|
|
fn half_edge_source(&self, he_id: HalfEdgeId) -> VertexId {
|
|
self.half_edge(he_id).origin
|
|
}
|
|
|
|
/// Convert a slice of half-edge IDs to a BezPath.
|
|
fn halfedges_to_bezpath(&self, hes: &[HalfEdgeId]) -> BezPath {
|
|
let mut path = BezPath::new();
|
|
if hes.is_empty() {
|
|
return path;
|
|
}
|
|
for (i, &he_id) in hes.iter().enumerate() {
|
|
let he = self.half_edge(he_id);
|
|
let edge_data = self.edge(he.edge);
|
|
let is_forward = edge_data.half_edges[0] == he_id;
|
|
let curve = if is_forward {
|
|
edge_data.curve
|
|
} else {
|
|
CubicBez::new(
|
|
edge_data.curve.p3,
|
|
edge_data.curve.p2,
|
|
edge_data.curve.p1,
|
|
edge_data.curve.p0,
|
|
)
|
|
};
|
|
if i == 0 {
|
|
path.move_to(curve.p0);
|
|
}
|
|
path.curve_to(curve.p1, curve.p2, curve.p3);
|
|
}
|
|
path.close_path();
|
|
path
|
|
}
|
|
|
|
/// Build a BezPath for a face with spur edges stripped (for fill rendering).
|
|
///
|
|
/// Spur edges cause fill rendering artifacts because the back-and-forth
|
|
/// path can enclose neighboring regions. Use this for all rendering;
|
|
/// use `face_to_bezpath` (raw) for topology operations like winding tests.
|
|
pub fn face_to_bezpath_stripped(&self, face_id: FaceId) -> BezPath {
|
|
let boundary = self.face_boundary(face_id);
|
|
self.cycle_to_bezpath_stripped(&boundary)
|
|
}
|
|
|
|
/// Build a BezPath for a face including holes (for correct filled rendering).
|
|
/// Outer boundary is CCW, holes are CW (opposite winding for non-zero fill).
|
|
/// Spur edges are stripped.
|
|
pub fn face_to_bezpath_with_holes(&self, face_id: FaceId) -> BezPath {
|
|
let boundary = self.face_boundary(face_id);
|
|
let mut path = self.cycle_to_bezpath_stripped(&boundary);
|
|
|
|
let face = self.face(face_id);
|
|
for &inner_he in &face.inner_half_edges {
|
|
let hole_cycle = self.walk_cycle(inner_he);
|
|
let hole_path = self.cycle_to_bezpath_stripped(&hole_cycle);
|
|
for el in hole_path.elements() {
|
|
path.push(*el);
|
|
}
|
|
}
|
|
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)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Check all DCEL invariants. Panics on violation. Only run in debug/test.
|
|
pub fn validate(&self) {
|
|
// 1. Twin symmetry: twin(twin(he)) == he
|
|
for (i, he) in self.half_edges.iter().enumerate() {
|
|
if he.deleted {
|
|
continue;
|
|
}
|
|
let he_id = HalfEdgeId(i as u32);
|
|
let twin = he.twin;
|
|
assert!(
|
|
!twin.is_none(),
|
|
"half-edge {:?} has NONE twin",
|
|
he_id
|
|
);
|
|
assert!(
|
|
!self.half_edges[twin.idx()].deleted,
|
|
"half-edge {:?} twin {:?} is deleted",
|
|
he_id,
|
|
twin
|
|
);
|
|
assert_eq!(
|
|
self.half_edges[twin.idx()].twin,
|
|
he_id,
|
|
"twin symmetry violated for {:?}",
|
|
he_id
|
|
);
|
|
}
|
|
|
|
// 2. Next/prev consistency: next(prev(he)) == he, prev(next(he)) == he
|
|
for (i, he) in self.half_edges.iter().enumerate() {
|
|
if he.deleted {
|
|
continue;
|
|
}
|
|
let he_id = HalfEdgeId(i as u32);
|
|
assert!(
|
|
!he.next.is_none(),
|
|
"half-edge {:?} has NONE next",
|
|
he_id
|
|
);
|
|
assert!(
|
|
!he.prev.is_none(),
|
|
"half-edge {:?} has NONE prev",
|
|
he_id
|
|
);
|
|
assert_eq!(
|
|
self.half_edges[he.next.idx()].prev,
|
|
he_id,
|
|
"next.prev != self for {:?}",
|
|
he_id
|
|
);
|
|
assert_eq!(
|
|
self.half_edges[he.prev.idx()].next,
|
|
he_id,
|
|
"prev.next != self for {:?}",
|
|
he_id
|
|
);
|
|
}
|
|
|
|
// 3. Face boundary cycles: every non-deleted half-edge's next-chain
|
|
// forms a cycle, and all half-edges in the cycle share the same face.
|
|
let mut visited = vec![false; self.half_edges.len()];
|
|
for (i, he) in self.half_edges.iter().enumerate() {
|
|
if he.deleted || visited[i] {
|
|
continue;
|
|
}
|
|
let start = HalfEdgeId(i as u32);
|
|
let face = he.face;
|
|
let mut current = start;
|
|
let mut count = 0;
|
|
loop {
|
|
assert!(
|
|
!self.half_edges[current.idx()].deleted,
|
|
"cycle contains deleted half-edge {:?}",
|
|
current
|
|
);
|
|
assert_eq!(
|
|
self.half_edges[current.idx()].face,
|
|
face,
|
|
"half-edge {:?} has face {:?} but cycle started with face {:?}",
|
|
current,
|
|
self.half_edges[current.idx()].face,
|
|
face
|
|
);
|
|
visited[current.idx()] = true;
|
|
current = self.half_edges[current.idx()].next;
|
|
count += 1;
|
|
if current == start {
|
|
break;
|
|
}
|
|
assert!(
|
|
count <= self.half_edges.len(),
|
|
"infinite cycle from {:?}",
|
|
start
|
|
);
|
|
}
|
|
}
|
|
|
|
// 4. Vertex outgoing: every non-deleted vertex's outgoing half-edge
|
|
// originates from that vertex.
|
|
for (i, v) in self.vertices.iter().enumerate() {
|
|
if v.deleted {
|
|
continue;
|
|
}
|
|
let v_id = VertexId(i as u32);
|
|
if !v.outgoing.is_none() {
|
|
let he = &self.half_edges[v.outgoing.idx()];
|
|
assert!(
|
|
!he.deleted,
|
|
"vertex {:?} outgoing {:?} is deleted",
|
|
v_id,
|
|
v.outgoing
|
|
);
|
|
assert_eq!(
|
|
he.origin, v_id,
|
|
"vertex {:?} outgoing {:?} has origin {:?}",
|
|
v_id, v.outgoing, he.origin
|
|
);
|
|
}
|
|
}
|
|
|
|
// 5. Edge half-edge consistency
|
|
for (i, e) in self.edges.iter().enumerate() {
|
|
if e.deleted {
|
|
continue;
|
|
}
|
|
let e_id = EdgeId(i as u32);
|
|
for &he_id in &e.half_edges {
|
|
assert!(
|
|
!he_id.is_none(),
|
|
"edge {:?} has NONE half-edge",
|
|
e_id
|
|
);
|
|
assert_eq!(
|
|
self.half_edges[he_id.idx()].edge,
|
|
e_id,
|
|
"edge {:?} half-edge {:?} doesn't point back",
|
|
e_id,
|
|
he_id
|
|
);
|
|
}
|
|
// The two half-edges should be twins
|
|
assert_eq!(
|
|
self.half_edges[e.half_edges[0].idx()].twin,
|
|
e.half_edges[1],
|
|
"edge {:?} half-edges are not twins",
|
|
e_id
|
|
);
|
|
}
|
|
|
|
// 6. Curve endpoints match vertex positions: for every edge,
|
|
// curve.p0 must equal the origin of half_edges[0] and
|
|
// curve.p3 must equal the origin of half_edges[1].
|
|
for (i, e) in self.edges.iter().enumerate() {
|
|
if e.deleted { continue; }
|
|
let e_id = EdgeId(i as u32);
|
|
let v0 = self.half_edges[e.half_edges[0].idx()].origin;
|
|
let v1 = self.half_edges[e.half_edges[1].idx()].origin;
|
|
let p0 = self.vertices[v0.idx()].position;
|
|
let p3 = self.vertices[v1.idx()].position;
|
|
let d0 = (e.curve.p0 - p0).hypot();
|
|
let d3 = (e.curve.p3 - p3).hypot();
|
|
assert!(
|
|
d0 < 0.01,
|
|
"Edge {:?} curve.p0 ({:.2},{:.2}) doesn't match V{} ({:.2},{:.2}), dist={:.2}",
|
|
e_id, e.curve.p0.x, e.curve.p0.y, v0.0, p0.x, p0.y, d0
|
|
);
|
|
assert!(
|
|
d3 < 0.01,
|
|
"Edge {:?} curve.p3 ({:.2},{:.2}) doesn't match V{} ({:.2},{:.2}), dist={:.2}",
|
|
e_id, e.curve.p3.x, e.curve.p3.y, v1.0, p3.x, p3.y, d3
|
|
);
|
|
}
|
|
|
|
// 7. No unsplit crossings: every pair of non-deleted edges that
|
|
// geometrically cross must share a vertex at the crossing point.
|
|
// An interior crossing (away from endpoints) without a shared
|
|
// vertex means insert_stroke failed to split the edge.
|
|
if cfg!(debug_assertions) {
|
|
use crate::curve_intersections::find_curve_intersections;
|
|
|
|
// Collect live edges with their endpoint vertex IDs.
|
|
let live_edges: Vec<(EdgeId, CubicBez, [VertexId; 2])> = self
|
|
.edges
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, e)| !e.deleted)
|
|
.map(|(i, e)| {
|
|
let eid = EdgeId(i as u32);
|
|
let v0 = self.half_edges[e.half_edges[0].idx()].origin;
|
|
let v1 = self.half_edges[e.half_edges[1].idx()].origin;
|
|
(eid, e.curve, [v0, v1])
|
|
})
|
|
.collect();
|
|
|
|
for i in 0..live_edges.len() {
|
|
for j in (i + 1)..live_edges.len() {
|
|
let (eid_a, curve_a, verts_a) = &live_edges[i];
|
|
let (eid_b, curve_b, verts_b) = &live_edges[j];
|
|
|
|
// Shared endpoint vertices — intersections near endpoints are expected.
|
|
let shared: Vec<VertexId> = verts_a
|
|
.iter()
|
|
.filter(|v| verts_b.contains(v))
|
|
.copied()
|
|
.collect();
|
|
|
|
let hits = find_curve_intersections(curve_a, curve_b);
|
|
for hit in &hits {
|
|
let t1 = hit.t1;
|
|
let t2 = hit.t2.unwrap_or(0.5);
|
|
|
|
// Skip crossings near a shared endpoint vertex. After
|
|
// splitting at a crossing, the sub-curves can still graze
|
|
// each other near the shared vertex. Detect this by
|
|
// checking whether the t-value on each edge places the
|
|
// hit near the endpoint that IS the shared vertex.
|
|
// Requires both edges to be near the shared vertex —
|
|
// a T-junction has the hit near the stem's endpoint but
|
|
// interior on the bar, so it won't be skipped.
|
|
let near_shared = shared.iter().any(|&sv| {
|
|
let a_near = if verts_a[0] == sv {
|
|
t1 < 0.05
|
|
} else {
|
|
t1 > 0.95
|
|
};
|
|
let b_near = if verts_b[0] == sv {
|
|
t2 < 0.05
|
|
} else {
|
|
t2 > 0.95
|
|
};
|
|
a_near && b_near
|
|
});
|
|
if near_shared {
|
|
continue;
|
|
}
|
|
|
|
// Also skip if spatially close to a shared vertex
|
|
// (catches cases where t-based check is borderline).
|
|
let close_to_shared = shared.iter().any(|&sv| {
|
|
let sv_pos = self.vertex(sv).position;
|
|
(hit.point - sv_pos).hypot() < 2.0
|
|
});
|
|
if close_to_shared {
|
|
continue;
|
|
}
|
|
|
|
// Skip intersections that are at/near both endpoints
|
|
// (shared vertex at a T-junction or crossing already resolved).
|
|
let near_endpoint_a = t1 < 0.02 || t1 > 0.98;
|
|
let near_endpoint_b = t2 < 0.02 || t2 > 0.98;
|
|
if near_endpoint_a && near_endpoint_b {
|
|
continue;
|
|
}
|
|
|
|
// Interior crossing — check if ANY vertex exists near this point.
|
|
let has_vertex_at_crossing = self.vertices.iter().any(|v| {
|
|
!v.deleted && (v.position - hit.point).hypot() < 2.0
|
|
});
|
|
|
|
assert!(
|
|
has_vertex_at_crossing,
|
|
"Unsplit edge crossing: edge {:?} (t={:.3}) x edge {:?} (t={:.3}) \
|
|
at ({:.1}, {:.1}) — no vertex at crossing point.\n\
|
|
Edge A vertices: V{} ({:.1},{:.1}) → V{} ({:.1},{:.1})\n\
|
|
Edge B vertices: V{} ({:.1},{:.1}) → V{} ({:.1},{:.1})",
|
|
eid_a, t1, eid_b, t2, hit.point.x, hit.point.y,
|
|
verts_a[0].0, self.vertex(verts_a[0]).position.x, self.vertex(verts_a[0]).position.y,
|
|
verts_a[1].0, self.vertex(verts_a[1]).position.x, self.vertex(verts_a[1]).position.y,
|
|
verts_b[0].0, self.vertex(verts_b[0]).position.x, self.vertex(verts_b[0]).position.y,
|
|
verts_b[1].0, self.vertex(verts_b[1]).position.x, self.vertex(verts_b[1]).position.y,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Topology operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Result of inserting a stroke into the DCEL.
|
|
#[derive(Clone, Debug)]
|
|
pub struct InsertStrokeResult {
|
|
/// All new vertex IDs created.
|
|
pub new_vertices: Vec<VertexId>,
|
|
/// All new edge IDs created.
|
|
pub new_edges: Vec<EdgeId>,
|
|
/// Existing edges that were split: (original_edge, parameter, new_vertex, new_edge).
|
|
pub split_edges: Vec<(EdgeId, f64, VertexId, EdgeId)>,
|
|
/// New face IDs created by edge insertion.
|
|
pub new_faces: Vec<FaceId>,
|
|
}
|
|
|
|
impl Dcel {
|
|
// -----------------------------------------------------------------------
|
|
// insert_edge: add an edge between two vertices on the same face boundary
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Insert an edge between `v1` and `v2` within `face`, splitting it into two faces.
|
|
///
|
|
/// Both vertices must be on the boundary of `face`. The new edge's curve is `curve`.
|
|
/// Returns `(new_edge_id, new_face_id)` where the new face is on one side of the edge.
|
|
///
|
|
/// If `v1 == v2` or the vertices are not both on the face boundary, this will panic
|
|
/// in debug mode.
|
|
pub fn insert_edge(
|
|
&mut self,
|
|
v1: VertexId,
|
|
v2: VertexId,
|
|
face: FaceId,
|
|
curve: CubicBez,
|
|
) -> (EdgeId, FaceId) {
|
|
debug_assert!(v1 != v2, "cannot insert edge from vertex to itself");
|
|
|
|
let v1_has_edges = !self.vertices[v1.idx()].outgoing.is_none();
|
|
let v2_has_edges = !self.vertices[v2.idx()].outgoing.is_none();
|
|
|
|
// Allocate the new edge and half-edge pair
|
|
let (he_fwd, he_bwd) = self.alloc_half_edge_pair();
|
|
let edge_id = self.alloc_edge(curve);
|
|
|
|
// Wire edge ↔ half-edges
|
|
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;
|
|
|
|
// Set origins
|
|
self.half_edges[he_fwd.idx()].origin = v1;
|
|
self.half_edges[he_bwd.idx()].origin = v2;
|
|
|
|
match (v1_has_edges, v2_has_edges) {
|
|
(false, false) => {
|
|
// Both vertices are isolated (no existing edges). This is the first
|
|
// edge in this face. Wire next/prev to form two trivial cycles.
|
|
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;
|
|
|
|
// Both half-edges are on the same face initially (no real split).
|
|
self.half_edges[he_fwd.idx()].face = face;
|
|
self.half_edges[he_bwd.idx()].face = face;
|
|
|
|
// Set face outer half-edge if unset
|
|
if self.faces[face.idx()].outer_half_edge.is_none() || face.0 == 0 {
|
|
if face.0 == 0 {
|
|
self.faces[0].inner_half_edges.push(he_fwd);
|
|
} else {
|
|
self.faces[face.idx()].outer_half_edge = he_fwd;
|
|
}
|
|
}
|
|
|
|
// Set vertex outgoing
|
|
if self.vertices[v1.idx()].outgoing.is_none() {
|
|
self.vertices[v1.idx()].outgoing = he_fwd;
|
|
}
|
|
if self.vertices[v2.idx()].outgoing.is_none() {
|
|
self.vertices[v2.idx()].outgoing = he_bwd;
|
|
}
|
|
|
|
return (edge_id, face);
|
|
}
|
|
(true, true) => {
|
|
// Both vertices have existing edges. Use angular position to find
|
|
// the correct sector in each vertex's fan for the splice.
|
|
//
|
|
// The standard DCEL rule: at a vertex with outgoing half-edges
|
|
// sorted CCW by angle, the new edge goes between the half-edge
|
|
// just before it (CW) and just after it (CCW). he_from_v is the
|
|
// CCW successor — the existing outgoing half-edge that will follow
|
|
// the new edge in the fan after insertion.
|
|
let fwd_angle = Self::curve_angle_at_start(&curve);
|
|
let bwd_angle = Self::curve_angle_at_end(&curve);
|
|
|
|
let he_from_v1 = self.find_ccw_successor(v1, fwd_angle);
|
|
let he_from_v2 = self.find_ccw_successor(v2, bwd_angle);
|
|
|
|
let he_into_v1 = self.half_edges[he_from_v1.idx()].prev;
|
|
let he_into_v2 = self.half_edges[he_from_v2.idx()].prev;
|
|
|
|
let actual_face = self.half_edges[he_into_v1.idx()].face;
|
|
|
|
if cfg!(test) && std::env::var("DCEL_TRACE").is_ok() {
|
|
let face_v1 = self.half_edges[he_into_v1.idx()].face;
|
|
let face_v2 = self.half_edges[he_into_v2.idx()].face;
|
|
eprintln!(" (true,true) v1=V{} v2=V{} fwd_angle={:.3} bwd_angle={:.3}",
|
|
v1.0, v2.0, fwd_angle, bwd_angle);
|
|
// Dump fan at v1
|
|
{
|
|
let start = self.vertices[v1.idx()].outgoing;
|
|
let mut cur = start;
|
|
eprint!(" v1 fan:");
|
|
loop {
|
|
let a = self.outgoing_angle(cur);
|
|
let f = self.half_edge(cur).face;
|
|
eprint!(" HE{}(a={:.3},F{})", cur.0, a, f.0);
|
|
let twin = self.half_edge(cur).twin;
|
|
cur = self.half_edge(twin).next;
|
|
if cur == start { break; }
|
|
}
|
|
eprintln!();
|
|
}
|
|
// Dump fan at v2
|
|
{
|
|
let start = self.vertices[v2.idx()].outgoing;
|
|
let mut cur = start;
|
|
eprint!(" v2 fan:");
|
|
loop {
|
|
let a = self.outgoing_angle(cur);
|
|
let f = self.half_edge(cur).face;
|
|
eprint!(" HE{}(a={:.3},F{})", cur.0, a, f.0);
|
|
let twin = self.half_edge(cur).twin;
|
|
cur = self.half_edge(twin).next;
|
|
if cur == start { break; }
|
|
}
|
|
eprintln!();
|
|
}
|
|
eprintln!(" he_from_v1=HE{} he_into_v1=HE{} face_at_v1=F{}",
|
|
he_from_v1.0, he_into_v1.0, face_v1.0);
|
|
eprintln!(" he_from_v2=HE{} he_into_v2=HE{} face_at_v2=F{}",
|
|
he_from_v2.0, he_into_v2.0, face_v2.0);
|
|
}
|
|
|
|
// Splice: he_into_v1 → he_fwd → he_from_v2 → ...
|
|
// he_into_v2 → he_bwd → he_from_v1 → ...
|
|
self.half_edges[he_fwd.idx()].next = he_from_v2;
|
|
self.half_edges[he_fwd.idx()].prev = he_into_v1;
|
|
self.half_edges[he_into_v1.idx()].next = he_fwd;
|
|
self.half_edges[he_from_v2.idx()].prev = he_fwd;
|
|
|
|
self.half_edges[he_bwd.idx()].next = he_from_v1;
|
|
self.half_edges[he_bwd.idx()].prev = he_into_v2;
|
|
self.half_edges[he_into_v2.idx()].next = he_bwd;
|
|
self.half_edges[he_from_v1.idx()].prev = he_bwd;
|
|
|
|
// Detect split vs bridge: walk from he_fwd and check if
|
|
// we encounter he_bwd (same cycle = bridge) or return to
|
|
// he_fwd without seeing it (separate cycles = split).
|
|
let is_split = {
|
|
let mut cur = self.half_edges[he_fwd.idx()].next;
|
|
let mut found = false;
|
|
while cur != he_fwd {
|
|
if cur == he_bwd {
|
|
found = true;
|
|
break;
|
|
}
|
|
cur = self.half_edges[cur.idx()].next;
|
|
}
|
|
!found
|
|
};
|
|
|
|
if cfg!(test) && std::env::var("DCEL_TRACE").is_ok() {
|
|
// Dump the cycle from he_fwd
|
|
eprint!(" fwd_cycle:");
|
|
let mut cur = he_fwd;
|
|
let mut count = 0;
|
|
loop {
|
|
eprint!(" HE{}", cur.0);
|
|
cur = self.half_edges[cur.idx()].next;
|
|
count += 1;
|
|
if cur == he_fwd || count > 50 { break; }
|
|
}
|
|
eprintln!(" (len={})", count);
|
|
eprint!(" bwd_cycle:");
|
|
cur = he_bwd;
|
|
count = 0;
|
|
loop {
|
|
eprint!(" HE{}", cur.0);
|
|
cur = self.half_edges[cur.idx()].next;
|
|
count += 1;
|
|
if cur == he_bwd || count > 50 { break; }
|
|
}
|
|
eprintln!(" (len={})", count);
|
|
eprintln!(" is_split={is_split} actual_face=F{}", actual_face.0);
|
|
}
|
|
|
|
if is_split {
|
|
// Normal case: splice split one cycle into two.
|
|
let new_face = self.alloc_face();
|
|
|
|
// Decide which cycle keeps actual_face and which gets new_face.
|
|
//
|
|
// For the unbounded face (FaceId(0)), we must keep FaceId(0) on
|
|
// the exterior cycle. The interior (bounded) cycle becomes the
|
|
// new face. We detect this by computing the signed area of each
|
|
// cycle via the bezpath: positive area = CCW interior, negative
|
|
// or larger absolute = CW exterior.
|
|
// Compute signed area of both cycles to determine which
|
|
// keeps the old face. The larger cycle (by absolute area)
|
|
// retains actual_face; the smaller one gets new_face.
|
|
// This is essential for both the unbounded face (where the
|
|
// exterior must stay as face 0) and bounded faces (where
|
|
// the wrong assignment causes bloated face cycles).
|
|
let fwd_cycle = self.walk_cycle(he_fwd);
|
|
let bwd_cycle = self.walk_cycle(he_bwd);
|
|
let fwd_path = self.cycle_to_bezpath(&fwd_cycle);
|
|
let bwd_path = self.cycle_to_bezpath(&bwd_cycle);
|
|
let fwd_area = kurbo::Shape::area(&fwd_path);
|
|
let bwd_area = kurbo::Shape::area(&bwd_path);
|
|
let (he_old, he_new) = if fwd_area.abs() < bwd_area.abs() {
|
|
(he_bwd, he_fwd)
|
|
} else {
|
|
(he_fwd, he_bwd)
|
|
};
|
|
|
|
self.half_edges[he_old.idx()].face = actual_face;
|
|
{
|
|
let mut cur = self.half_edges[he_old.idx()].next;
|
|
while cur != he_old {
|
|
self.half_edges[cur.idx()].face = actual_face;
|
|
cur = self.half_edges[cur.idx()].next;
|
|
}
|
|
}
|
|
self.half_edges[he_new.idx()].face = new_face;
|
|
{
|
|
let mut cur = self.half_edges[he_new.idx()].next;
|
|
while cur != he_new {
|
|
self.half_edges[cur.idx()].face = new_face;
|
|
cur = self.half_edges[cur.idx()].next;
|
|
}
|
|
}
|
|
|
|
self.faces[actual_face.idx()].outer_half_edge = he_old;
|
|
self.faces[new_face.idx()].outer_half_edge = he_new;
|
|
|
|
return (edge_id, new_face);
|
|
} else {
|
|
// Bridge case: splice merged two cycles into one.
|
|
// No face split — assign the whole cycle to actual_face.
|
|
self.half_edges[he_fwd.idx()].face = actual_face;
|
|
{
|
|
let mut cur = self.half_edges[he_fwd.idx()].next;
|
|
while cur != he_fwd {
|
|
self.half_edges[cur.idx()].face = actual_face;
|
|
cur = self.half_edges[cur.idx()].next;
|
|
}
|
|
}
|
|
if actual_face.0 != 0 {
|
|
self.faces[actual_face.idx()].outer_half_edge = he_fwd;
|
|
}
|
|
|
|
return (edge_id, actual_face);
|
|
}
|
|
}
|
|
_ => {
|
|
// One vertex has edges, the other is isolated.
|
|
// This creates a "spur" (antenna) edge — no face split.
|
|
let (connected_v, isolated_v) = if v1_has_edges {
|
|
(v1, v2)
|
|
} else {
|
|
(v2, v1)
|
|
};
|
|
|
|
// he_out: new half-edge FROM connected_v TO isolated_v
|
|
// he_back: new half-edge FROM isolated_v TO connected_v
|
|
let (he_out, he_back) = if self.half_edges[he_fwd.idx()].origin == connected_v {
|
|
(he_fwd, he_bwd)
|
|
} else {
|
|
(he_bwd, he_fwd)
|
|
};
|
|
|
|
// Find correct sector at connected vertex using angle
|
|
let spur_angle = if self.half_edges[he_fwd.idx()].origin == connected_v {
|
|
Self::curve_angle_at_start(&curve)
|
|
} else {
|
|
Self::curve_angle_at_end(&curve)
|
|
};
|
|
let existing_he = self.find_ccw_successor(connected_v, spur_angle);
|
|
|
|
let he_into_connected = self.half_edges[existing_he.idx()].prev;
|
|
let actual_face = self.half_edges[he_into_connected.idx()].face;
|
|
|
|
// Splice spur into the cycle at connected_v:
|
|
// Before: ... → he_into_connected → existing_he → ...
|
|
// After: ... → he_into_connected → he_out → he_back → existing_he → ...
|
|
self.half_edges[he_into_connected.idx()].next = he_out;
|
|
self.half_edges[he_out.idx()].prev = he_into_connected;
|
|
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 = existing_he;
|
|
self.half_edges[existing_he.idx()].prev = he_back;
|
|
|
|
// Both half-edges are on the same face (no split)
|
|
self.half_edges[he_out.idx()].face = actual_face;
|
|
self.half_edges[he_back.idx()].face = actual_face;
|
|
|
|
// Isolated vertex's outgoing must originate FROM isolated_v
|
|
self.vertices[isolated_v.idx()].outgoing = he_back;
|
|
|
|
return (edge_id, actual_face);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find the outgoing half-edge from `vertex` that is the immediate CCW
|
|
/// successor of `new_angle` in the vertex fan.
|
|
///
|
|
/// In the DCEL fan around a vertex, outgoing half-edges are ordered by
|
|
/// angle with the rule `twin(out[i]).next = out[(i+1) % n]`. Inserting a
|
|
/// new edge at `new_angle` requires splicing before this CCW successor.
|
|
fn find_ccw_successor(&self, vertex: VertexId, new_angle: f64) -> HalfEdgeId {
|
|
let v = self.vertex(vertex);
|
|
debug_assert!(!v.outgoing.is_none(), "find_ccw_successor on isolated vertex");
|
|
|
|
let start = v.outgoing;
|
|
let mut best_he = start;
|
|
let mut best_delta = f64::MAX;
|
|
|
|
let mut current = start;
|
|
loop {
|
|
let angle = self.outgoing_angle(current);
|
|
// How far CCW from new_angle to this half-edge's angle
|
|
let mut delta = angle - new_angle;
|
|
if delta <= 0.0 {
|
|
delta += std::f64::consts::TAU;
|
|
}
|
|
if delta < best_delta {
|
|
best_delta = delta;
|
|
best_he = current;
|
|
}
|
|
|
|
let twin = self.half_edge(current).twin;
|
|
current = self.half_edge(twin).next;
|
|
if current == start {
|
|
break;
|
|
}
|
|
}
|
|
|
|
best_he
|
|
}
|
|
|
|
/// Outgoing angle of a curve at its start point (p0 → p1, fallback p3).
|
|
fn curve_angle_at_start(curve: &CubicBez) -> f64 {
|
|
let from = curve.p0;
|
|
let dx = curve.p1.x - from.x;
|
|
let dy = curve.p1.y - from.y;
|
|
if dx * dx + dy * dy > 1e-18 {
|
|
dy.atan2(dx)
|
|
} else {
|
|
(curve.p3.y - from.y).atan2(curve.p3.x - from.x)
|
|
}
|
|
}
|
|
|
|
/// Outgoing angle of the backward half-edge at the curve's end point
|
|
/// (p3 → p2, fallback p0).
|
|
fn curve_angle_at_end(curve: &CubicBez) -> f64 {
|
|
let from = curve.p3;
|
|
let dx = curve.p2.x - from.x;
|
|
let dy = curve.p2.y - from.y;
|
|
if dx * dx + dy * dy > 1e-18 {
|
|
dy.atan2(dx)
|
|
} else {
|
|
(curve.p0.y - from.y).atan2(curve.p0.x - from.x)
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// split_edge: split an edge at parameter t via de Casteljau
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Split an edge at parameter `t` (0..1), inserting a new vertex at the split point.
|
|
/// The original edge is shortened to [0, t], a new edge covers [t, 1].
|
|
/// If an existing vertex is within snap tolerance of the split point,
|
|
/// it is reused so that crossing strokes share the same vertex.
|
|
/// Returns `(new_vertex_id, new_edge_id)`.
|
|
pub fn split_edge(&mut self, edge_id: EdgeId, t: f64) -> (VertexId, EdgeId) {
|
|
debug_assert!((0.0..=1.0).contains(&t), "t must be in [0, 1]");
|
|
|
|
let original_curve = self.edges[edge_id.idx()].curve;
|
|
// De Casteljau subdivision
|
|
let (mut curve_a, mut curve_b) = subdivide_cubic(original_curve, t);
|
|
|
|
let split_point = curve_a.p3; // == curve_b.p0
|
|
let new_vertex = self
|
|
.snap_vertex(split_point, DEFAULT_SNAP_EPSILON)
|
|
.unwrap_or_else(|| self.alloc_vertex(split_point));
|
|
|
|
// If the vertex was snapped to a different position, adjust curve
|
|
// endpoints so they exactly match the vertex. Without this, the
|
|
// SVG curves and the vertex circles drift apart and different curve
|
|
// pairs that cross at the same visual point produce vertices that
|
|
// never merge.
|
|
let vpos = self.vertices[new_vertex.idx()].position;
|
|
curve_a.p3 = vpos;
|
|
curve_b.p0 = vpos;
|
|
|
|
// Get the original half-edges
|
|
let [he_fwd, he_bwd] = self.edges[edge_id.idx()].half_edges;
|
|
|
|
// Allocate new edge and half-edge pair for the second segment
|
|
let (new_he_fwd, new_he_bwd) = self.alloc_half_edge_pair();
|
|
let new_edge_id = self.alloc_edge(curve_b);
|
|
|
|
// Wire new edge ↔ half-edges
|
|
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 from original edge
|
|
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;
|
|
|
|
// Update original edge's curve to the first segment
|
|
self.edges[edge_id.idx()].curve = curve_a;
|
|
|
|
// Set origins for new half-edges
|
|
// new_he_fwd goes from new_vertex toward the old destination
|
|
// new_he_bwd goes from old destination toward new_vertex
|
|
self.half_edges[new_he_fwd.idx()].origin = new_vertex;
|
|
// new_he_bwd's origin = old destination of he_fwd = origin of he_bwd's twin...
|
|
// Actually, he_bwd.origin = destination of original forward edge
|
|
self.half_edges[new_he_bwd.idx()].origin = self.half_edges[he_bwd.idx()].origin;
|
|
|
|
// Now splice into the boundary cycles.
|
|
// Forward direction: ... → he_fwd → he_fwd.next → ...
|
|
// becomes: ... → he_fwd → new_he_fwd → old_he_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;
|
|
|
|
// Backward direction: ... → he_bwd → he_bwd.next → ...
|
|
// becomes: ... → new_he_bwd → he_bwd → he_bwd.next → ...
|
|
// (new_he_bwd is inserted before he_bwd)
|
|
let bwd_prev = self.half_edges[he_bwd.idx()].prev;
|
|
self.half_edges[he_bwd.idx()].prev = new_he_bwd;
|
|
self.half_edges[new_he_bwd.idx()].next = he_bwd;
|
|
self.half_edges[new_he_bwd.idx()].prev = bwd_prev;
|
|
self.half_edges[bwd_prev.idx()].next = new_he_bwd;
|
|
self.half_edges[new_he_bwd.idx()].face = self.half_edges[he_bwd.idx()].face;
|
|
|
|
// Update he_bwd's origin to the new vertex (it now covers [new_vertex → v1])
|
|
// new_he_bwd covers [old_dest → new_vertex]
|
|
let old_dest = self.half_edges[he_bwd.idx()].origin;
|
|
self.half_edges[he_bwd.idx()].origin = new_vertex;
|
|
|
|
// Update old destination vertex's outgoing: it was pointing at he_bwd,
|
|
// but he_bwd.origin is now new_vertex. new_he_bwd has origin = old_dest.
|
|
if self.vertices[old_dest.idx()].outgoing == he_bwd {
|
|
self.vertices[old_dest.idx()].outgoing = new_he_bwd;
|
|
}
|
|
|
|
// Set new vertex's outgoing half-edge
|
|
self.vertices[new_vertex.idx()].outgoing = new_he_fwd;
|
|
|
|
(new_vertex, new_edge_id)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// remove_edge: remove an edge, merging the two adjacent faces
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Remove an edge, merging its two adjacent faces into one.
|
|
/// Returns the surviving face ID.
|
|
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;
|
|
|
|
// The surviving face (prefer lower ID, always keep face 0)
|
|
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;
|
|
|
|
// Check if removing this edge leaves isolated vertices
|
|
let v1 = self.half_edges[he_fwd.idx()].origin;
|
|
let v2 = self.half_edges[he_bwd.idx()].origin;
|
|
|
|
// Splice out the half-edges from boundary cycles
|
|
if fwd_next == he_bwd && bwd_next == he_fwd {
|
|
// The edge forms a complete boundary by itself (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 {
|
|
// he_fwd → he_bwd is a spur (consecutive in cycle):
|
|
// ... → fwd_prev → he_fwd → he_bwd → bwd_next → ...
|
|
// Splice both out: fwd_prev → bwd_next
|
|
self.half_edges[fwd_prev.idx()].next = bwd_next;
|
|
self.half_edges[bwd_next.idx()].prev = fwd_prev;
|
|
// v2 (origin of he_bwd) becomes isolated
|
|
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
|
// Update v1's outgoing if needed
|
|
if self.vertices[v1.idx()].outgoing == he_fwd {
|
|
self.vertices[v1.idx()].outgoing = bwd_next;
|
|
}
|
|
} else if bwd_next == he_fwd {
|
|
// he_bwd → he_fwd is a spur (consecutive in cycle):
|
|
// ... → bwd_prev → he_bwd → he_fwd → fwd_next → ...
|
|
// Splice both out: bwd_prev → fwd_next
|
|
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 case: 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;
|
|
|
|
// Update vertex outgoing pointers if they pointed to removed half-edges
|
|
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 all half-edges from dying face to surviving face
|
|
if surviving != dying && !dying.is_none() {
|
|
// Find a valid starting half-edge for the walk.
|
|
// The dying face's outer_half_edge may point to one of the removed half-edges,
|
|
// so we use a surviving neighbor (fwd_next or bwd_next) that was spliced in.
|
|
let dying_ohe = self.faces[dying.idx()].outer_half_edge;
|
|
let walk_start = if dying_ohe.is_none() {
|
|
HalfEdgeId::NONE
|
|
} else if dying_ohe != he_fwd && dying_ohe != he_bwd {
|
|
dying_ohe
|
|
} else {
|
|
// The outer_half_edge was removed; use a surviving neighbor instead.
|
|
// After splicing, fwd_next and bwd_next are the half-edges that replaced
|
|
// the removed ones in the cycle. Pick one that belongs to dying face.
|
|
if !fwd_next.is_none() && fwd_next != he_fwd && fwd_next != he_bwd {
|
|
fwd_next
|
|
} else if !bwd_next.is_none() && bwd_next != he_fwd && bwd_next != he_bwd {
|
|
bwd_next
|
|
} else {
|
|
HalfEdgeId::NONE
|
|
}
|
|
};
|
|
|
|
if !walk_start.is_none() {
|
|
let mut cur = walk_start;
|
|
loop {
|
|
self.half_edges[cur.idx()].face = surviving;
|
|
cur = self.half_edges[cur.idx()].next;
|
|
if cur == walk_start {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge inner half-edges (holes) from dying into surviving
|
|
let inner = std::mem::take(&mut self.faces[dying.idx()].inner_half_edges);
|
|
self.faces[surviving.idx()].inner_half_edges.extend(inner);
|
|
}
|
|
|
|
// Update surviving face's outer half-edge if it pointed to a removed half-edge
|
|
if self.faces[surviving.idx()].outer_half_edge == he_fwd
|
|
|| self.faces[surviving.idx()].outer_half_edge == he_bwd
|
|
{
|
|
// Find a remaining half-edge on this face
|
|
if fwd_next != he_bwd && !self.half_edges[fwd_next.idx()].deleted {
|
|
self.faces[surviving.idx()].outer_half_edge = fwd_next;
|
|
} else if bwd_next != he_fwd && !self.half_edges[bwd_next.idx()].deleted {
|
|
self.faces[surviving.idx()].outer_half_edge = bwd_next;
|
|
} else {
|
|
self.faces[surviving.idx()].outer_half_edge = HalfEdgeId::NONE;
|
|
}
|
|
}
|
|
|
|
// Remove inner_half_edges references to removed half-edges
|
|
self.faces[surviving.idx()]
|
|
.inner_half_edges
|
|
.retain(|&he| he != he_fwd && he != he_bwd);
|
|
|
|
// Free the removed elements
|
|
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
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// insert_stroke: compound operation for adding a multi-segment stroke
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Insert a stroke (sequence of cubic bezier segments) into the DCEL.
|
|
///
|
|
/// This is the main entry point for the Draw tool. It:
|
|
/// 1. Snaps stroke endpoints to nearby existing vertices (within epsilon)
|
|
/// 2. Finds intersections between stroke segments and existing edges
|
|
/// 3. Splits existing edges at intersection points
|
|
/// 4. Inserts new vertices and edges for the stroke segments
|
|
/// 5. Updates face topology as edges split faces
|
|
///
|
|
/// The segments should be connected end-to-end (segment[i].p3 == segment[i+1].p0).
|
|
pub fn insert_stroke(
|
|
&mut self,
|
|
segments: &[CubicBez],
|
|
stroke_style: Option<StrokeStyle>,
|
|
stroke_color: Option<ShapeColor>,
|
|
epsilon: f64,
|
|
) -> InsertStrokeResult {
|
|
use crate::curve_intersections::find_curve_intersections;
|
|
|
|
// Record the stroke for debug test generation
|
|
if let Some(ref mut rec) = self.debug_recorder {
|
|
eprintln!("[DCEL_RECORD] insert_stroke: recording {} segments (total strokes: {})",
|
|
segments.len(), rec.strokes.len() + 1);
|
|
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;
|
|
}
|
|
|
|
// Collect all intersection points between new segments and existing edges.
|
|
// For each new segment, we need to know where to split it, and for each
|
|
// existing edge, we need to know where to split it.
|
|
|
|
// Structure: for each new segment index, a sorted list of (t, point, existing_edge_id, t_on_existing)
|
|
#[allow(dead_code)]
|
|
struct StrokeIntersection {
|
|
t_on_segment: f64,
|
|
point: Point,
|
|
existing_edge: EdgeId,
|
|
t_on_existing: f64,
|
|
}
|
|
|
|
let mut segment_intersections: Vec<Vec<StrokeIntersection>> =
|
|
(0..segments.len()).map(|_| Vec::new()).collect();
|
|
|
|
// Find intersections with existing edges
|
|
let existing_edge_count = self.edges.len();
|
|
for (seg_idx, seg) in segments.iter().enumerate() {
|
|
for edge_idx in 0..existing_edge_count {
|
|
if self.edges[edge_idx].deleted {
|
|
continue;
|
|
}
|
|
let edge_id = EdgeId(edge_idx as u32);
|
|
let existing_curve = &self.edges[edge_idx].curve;
|
|
|
|
let intersections = find_curve_intersections(seg, existing_curve);
|
|
for inter in intersections {
|
|
if let Some(t2) = inter.t2 {
|
|
// Skip intersections at the very endpoints (these are handled by snapping)
|
|
if (inter.t1 < 0.001 || inter.t1 > 0.999)
|
|
&& (t2 < 0.001 || t2 > 0.999)
|
|
{
|
|
continue;
|
|
}
|
|
segment_intersections[seg_idx].push(StrokeIntersection {
|
|
t_on_segment: inter.t1,
|
|
point: inter.point,
|
|
existing_edge: edge_id,
|
|
t_on_existing: t2,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Sort by t on segment
|
|
segment_intersections[seg_idx]
|
|
.sort_by(|a, b| a.t_on_segment.partial_cmp(&b.t_on_segment).unwrap());
|
|
}
|
|
|
|
// Within-stroke self-intersections.
|
|
//
|
|
// There are two kinds:
|
|
// (a) A single cubic segment crosses itself (loop-shaped curve).
|
|
// (b) Two different segments of the stroke cross each other.
|
|
//
|
|
// For (a) we split each segment at its midpoint and intersect the two
|
|
// halves using the robust recursive finder, then remap t-values back to
|
|
// the original segment's parameter space.
|
|
//
|
|
// For (b) we check all (i, j) pairs where j > i. Adjacent pairs share
|
|
// an endpoint — we filter out that shared-endpoint hit (t1≈1, t2≈0).
|
|
struct IntraStrokeIntersection {
|
|
seg_a: usize,
|
|
t_on_a: f64,
|
|
seg_b: usize,
|
|
t_on_b: f64,
|
|
point: Point,
|
|
}
|
|
let mut intra_intersections: Vec<IntraStrokeIntersection> = Vec::new();
|
|
|
|
// (a) Single-segment self-intersections
|
|
for (i, seg) in segments.iter().enumerate() {
|
|
let left = seg.subsegment(0.0..0.5);
|
|
let right = seg.subsegment(0.5..1.0);
|
|
let hits = find_curve_intersections(&left, &right);
|
|
for inter in hits {
|
|
if let Some(t2) = inter.t2 {
|
|
// Remap from half-curve parameter space to full segment:
|
|
// left half [0,1] → segment [0, 0.5], right half [0,1] → segment [0.5, 1]
|
|
let t_on_seg_a = inter.t1 * 0.5;
|
|
let t_on_seg_b = 0.5 + t2 * 0.5;
|
|
// Skip the shared midpoint (t1≈1 on left, t2≈0 on right → seg t≈0.5 both)
|
|
if (t_on_seg_b - t_on_seg_a).abs() < 0.01 {
|
|
continue;
|
|
}
|
|
// Skip near-endpoint hits
|
|
if t_on_seg_a < 0.001 || t_on_seg_b > 0.999 {
|
|
continue;
|
|
}
|
|
intra_intersections.push(IntraStrokeIntersection {
|
|
seg_a: i,
|
|
t_on_a: t_on_seg_a,
|
|
seg_b: i,
|
|
t_on_b: t_on_seg_b,
|
|
point: inter.point,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// (b) Inter-segment crossings
|
|
for i in 0..segments.len() {
|
|
for j in (i + 1)..segments.len() {
|
|
let hits = find_curve_intersections(&segments[i], &segments[j]);
|
|
for inter in hits {
|
|
if let Some(t2) = inter.t2 {
|
|
// Skip near-endpoint hits: these are shared vertices between
|
|
// consecutive segments (t1≈1, t2≈0) or stroke start/end,
|
|
// not real crossings. Use a wider threshold for adjacent
|
|
// segments since the recursive finder can converge to t-values
|
|
// that are close-but-not-quite at the shared corner.
|
|
let tol = if j == i + 1 { 0.02 } else { 0.001 };
|
|
if (inter.t1 < tol || inter.t1 > 1.0 - tol)
|
|
&& (t2 < tol || t2 > 1.0 - tol)
|
|
{
|
|
continue;
|
|
}
|
|
intra_intersections.push(IntraStrokeIntersection {
|
|
seg_a: i,
|
|
t_on_a: inter.t1,
|
|
seg_b: j,
|
|
t_on_b: t2,
|
|
point: inter.point,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dedup nearby intra-stroke intersections (recursive finder can return
|
|
// near-duplicate hits for one crossing)
|
|
intra_intersections.sort_by(|a, b| {
|
|
a.seg_a
|
|
.cmp(&b.seg_a)
|
|
.then(a.seg_b.cmp(&b.seg_b))
|
|
.then(a.t_on_a.partial_cmp(&b.t_on_a).unwrap())
|
|
});
|
|
intra_intersections.dedup_by(|a, b| {
|
|
a.seg_a == b.seg_a
|
|
&& a.seg_b == b.seg_b
|
|
&& (a.point - b.point).hypot() < 1.0
|
|
});
|
|
|
|
// Create vertices for each intra-stroke crossing and record split points.
|
|
//
|
|
// For single-segment self-intersections (seg_a == seg_b), the loop
|
|
// sub-curve would go from vertex V back to V, which insert_edge
|
|
// doesn't support. We break the loop by adding a midpoint vertex
|
|
// halfway between the two crossing t-values, splitting the loop
|
|
// sub-curve into two halves.
|
|
let mut intra_split_points: Vec<Vec<(f64, VertexId)>> =
|
|
(0..segments.len()).map(|_| Vec::new()).collect();
|
|
|
|
for intra in &intra_intersections {
|
|
let v = self.alloc_vertex(intra.point);
|
|
result.new_vertices.push(v);
|
|
intra_split_points[intra.seg_a].push((intra.t_on_a, v));
|
|
if intra.seg_a == intra.seg_b {
|
|
// Same segment: add a midpoint vertex to break the V→V loop
|
|
let mid_t = (intra.t_on_a + intra.t_on_b) / 2.0;
|
|
let mid_point = segments[intra.seg_a].eval(mid_t);
|
|
let mid_v = self.alloc_vertex(mid_point);
|
|
result.new_vertices.push(mid_v);
|
|
intra_split_points[intra.seg_a].push((mid_t, mid_v));
|
|
}
|
|
intra_split_points[intra.seg_b].push((intra.t_on_b, v));
|
|
}
|
|
|
|
// Split existing edges at intersection points.
|
|
// We need to track how edge splits affect subsequent intersection parameters.
|
|
// Process from highest t to lowest per edge to avoid parameter shift.
|
|
struct EdgeSplit {
|
|
edge_id: EdgeId,
|
|
t: f64,
|
|
seg_idx: usize,
|
|
inter_idx: usize,
|
|
}
|
|
|
|
// Group intersections by existing edge
|
|
let mut splits_by_edge: std::collections::HashMap<u32, Vec<EdgeSplit>> =
|
|
std::collections::HashMap::new();
|
|
for (seg_idx, inters) in segment_intersections.iter().enumerate() {
|
|
for (inter_idx, inter) in inters.iter().enumerate() {
|
|
splits_by_edge
|
|
.entry(inter.existing_edge.0)
|
|
.or_default()
|
|
.push(EdgeSplit {
|
|
edge_id: inter.existing_edge,
|
|
t: inter.t_on_existing,
|
|
seg_idx,
|
|
inter_idx,
|
|
});
|
|
}
|
|
}
|
|
|
|
// For each existing edge, sort splits by t descending and apply them.
|
|
// Map from (seg_idx, inter_idx) to the vertex created at the split.
|
|
let mut split_vertex_map: std::collections::HashMap<(usize, usize), VertexId> =
|
|
std::collections::HashMap::new();
|
|
|
|
for (_edge_raw, mut splits) in splits_by_edge {
|
|
// Sort descending by t so we split from end to start.
|
|
// After each split, current_edge is the lower portion [0, t] in original
|
|
// parameter space. Its parameter 1.0 maps to t in original space.
|
|
splits.sort_by(|a, b| b.t.partial_cmp(&a.t).unwrap());
|
|
|
|
let current_edge = splits[0].edge_id;
|
|
// Upper bound of current_edge's range in original parameter space.
|
|
// Initially [0, 1], then [0, t_high] after first split, etc.
|
|
let mut current_t_end = 1.0_f64;
|
|
|
|
for split in &splits {
|
|
// Remap original t to current_edge's parameter space [0, 1]
|
|
// which maps to original [0, current_t_end].
|
|
let t_in_current = if current_t_end > 1e-12 {
|
|
split.t / current_t_end
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
if t_in_current < 0.001 || t_in_current > 0.999 {
|
|
// Too close to endpoint — snap to existing vertex instead
|
|
let vertex = if t_in_current <= 0.5 {
|
|
let he = self.edges[current_edge.idx()].half_edges[0];
|
|
self.half_edges[he.idx()].origin
|
|
} else {
|
|
let he = self.edges[current_edge.idx()].half_edges[1];
|
|
self.half_edges[he.idx()].origin
|
|
};
|
|
split_vertex_map.insert((split.seg_idx, split.inter_idx), vertex);
|
|
continue;
|
|
}
|
|
|
|
let (new_vertex, new_edge) = self.split_edge(current_edge, t_in_current);
|
|
result.split_edges.push((current_edge, split.t, new_vertex, new_edge));
|
|
split_vertex_map.insert((split.seg_idx, split.inter_idx), new_vertex);
|
|
|
|
// After splitting at t_in_current, current_edge now covers
|
|
// [0, split.t] in original space. Update the upper bound.
|
|
current_t_end = split.t;
|
|
let _ = new_edge;
|
|
}
|
|
}
|
|
|
|
// Now insert the stroke segments as edges.
|
|
// For each segment, split it at intersection points and create sub-edges.
|
|
// Collect the vertex chain for the entire stroke.
|
|
let mut stroke_vertices: Vec<VertexId> = Vec::new();
|
|
|
|
// First vertex: snap or create
|
|
let first_point = segments[0].p0;
|
|
let first_v = self
|
|
.snap_vertex(first_point, epsilon)
|
|
.unwrap_or_else(|| {
|
|
let v = self.alloc_vertex(first_point);
|
|
result.new_vertices.push(v);
|
|
v
|
|
});
|
|
stroke_vertices.push(first_v);
|
|
|
|
for (seg_idx, seg) in segments.iter().enumerate() {
|
|
let inters = &segment_intersections[seg_idx];
|
|
|
|
// Collect split points along this segment in order
|
|
let mut split_points: Vec<(f64, VertexId)> = Vec::new();
|
|
for (inter_idx, inter) in inters.iter().enumerate() {
|
|
if let Some(&vertex) = split_vertex_map.get(&(seg_idx, inter_idx)) {
|
|
split_points.push((inter.t_on_segment, vertex));
|
|
}
|
|
}
|
|
|
|
// Merge intra-stroke split points (self-crossing vertices)
|
|
if let Some(intra) = intra_split_points.get(seg_idx) {
|
|
for &(t, v) in intra {
|
|
split_points.push((t, v));
|
|
}
|
|
}
|
|
// Sort by t so all split points (existing-edge + intra-stroke) are in order
|
|
split_points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
|
|
|
// End vertex: snap or create
|
|
let end_point = seg.p3;
|
|
let end_v = if seg_idx + 1 < segments.len() {
|
|
// Interior join — snap to next segment's start (which should be the same point)
|
|
self.snap_vertex(end_point, epsilon).unwrap_or_else(|| {
|
|
let v = self.alloc_vertex(end_point);
|
|
result.new_vertices.push(v);
|
|
v
|
|
})
|
|
} else {
|
|
// Last segment endpoint
|
|
self.snap_vertex(end_point, epsilon).unwrap_or_else(|| {
|
|
let v = self.alloc_vertex(end_point);
|
|
result.new_vertices.push(v);
|
|
v
|
|
})
|
|
};
|
|
split_points.push((1.0, end_v));
|
|
|
|
// Create sub-edges from last vertex through split points
|
|
let mut prev_t = 0.0;
|
|
let mut prev_vertex = *stroke_vertices.last().unwrap();
|
|
|
|
for (t, vertex) in &split_points {
|
|
// Skip zero-length sub-edges: an intra-stroke split point near
|
|
// a segment endpoint can snap to the same vertex, producing a
|
|
// degenerate v→v edge.
|
|
if prev_vertex == *vertex {
|
|
prev_t = *t;
|
|
continue;
|
|
}
|
|
|
|
let mut sub_curve = subsegment_cubic(*seg, prev_t, *t);
|
|
|
|
// Adjust curve endpoints to exactly match vertex positions.
|
|
// Vertices may have been snapped to a nearby existing vertex,
|
|
// so the curve from subsegment_cubic can be a few pixels off.
|
|
sub_curve.p0 = self.vertices[prev_vertex.idx()].position;
|
|
sub_curve.p3 = self.vertices[vertex.idx()].position;
|
|
|
|
// Find the face containing this edge's midpoint for insertion
|
|
let mid = midpoint_of_cubic(&sub_curve);
|
|
let face = self.find_face_containing_point(mid);
|
|
|
|
if cfg!(test) && std::env::var("DCEL_TRACE").is_ok() {
|
|
let p1 = self.vertices[prev_vertex.idx()].position;
|
|
let p2 = self.vertices[vertex.idx()].position;
|
|
eprintln!(" insert_edge: V{}({:.1},{:.1}) → V{}({:.1},{:.1}) face=F{} mid=({:.1},{:.1})",
|
|
prev_vertex.0, p1.x, p1.y, vertex.0, p2.x, p2.y, face.0, mid.x, mid.y);
|
|
}
|
|
|
|
let (edge_id, maybe_new_face) =
|
|
self.insert_edge(prev_vertex, *vertex, face, sub_curve);
|
|
|
|
if cfg!(test) && std::env::var("DCEL_TRACE").is_ok() {
|
|
eprintln!(" → E{} new_face=F{}", edge_id.0, maybe_new_face.0);
|
|
}
|
|
|
|
// Apply stroke style
|
|
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 maybe_new_face != face && maybe_new_face.0 != 0 {
|
|
result.new_faces.push(maybe_new_face);
|
|
}
|
|
|
|
prev_t = *t;
|
|
prev_vertex = *vertex;
|
|
}
|
|
|
|
stroke_vertices.push(end_v);
|
|
}
|
|
|
|
// Post-insertion repair: check newly inserted stroke edges against ALL
|
|
// other edges for crossings that the pre-insertion detection missed.
|
|
// This can happen when an existing edge is split during insertion,
|
|
// creating a new upper-portion edge (index >= existing_edge_count)
|
|
// that was never checked against later stroke segments.
|
|
self.repair_unsplit_crossings(&mut result);
|
|
|
|
#[cfg(debug_assertions)]
|
|
self.validate();
|
|
|
|
result
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// repair_unsplit_crossings: post-insertion fix for missed intersections
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// After inserting stroke edges, check each new edge against every other
|
|
/// edge for interior crossings that lack a shared vertex. Split both
|
|
/// edges at each crossing and merge the resulting co-located vertices.
|
|
///
|
|
/// This catches crossings missed by the pre-insertion detection, which
|
|
/// only checks segments against `0..existing_edge_count` and therefore
|
|
/// misses edges created by `split_edge` during the insertion process.
|
|
fn repair_unsplit_crossings(&mut self, result: &mut InsertStrokeResult) {
|
|
use crate::curve_intersections::find_curve_intersections;
|
|
|
|
// We need to check every new edge against every other edge (both
|
|
// new and pre-existing). Collect new edge IDs into a set for
|
|
// fast membership lookup.
|
|
let new_edge_set: std::collections::HashSet<u32> = result
|
|
.new_edges
|
|
.iter()
|
|
.map(|e| e.0)
|
|
.collect();
|
|
|
|
// For each new edge, check against all other edges.
|
|
// We iterate by index because self is borrowed mutably during fixes.
|
|
let mut crossing_pairs: Vec<(EdgeId, f64, EdgeId, f64, Point)> = Vec::new();
|
|
|
|
// Snapshot: collect edge data so we don't borrow self during iteration.
|
|
let edge_infos: Vec<(EdgeId, CubicBez, [VertexId; 2], bool)> = self
|
|
.edges
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, e)| {
|
|
let eid = EdgeId(i as u32);
|
|
if e.deleted {
|
|
return (eid, CubicBez::new((0., 0.), (0., 0.), (0., 0.), (0., 0.)), [VertexId::NONE; 2], true);
|
|
}
|
|
let v0 = self.half_edges[e.half_edges[0].idx()].origin;
|
|
let v1 = self.half_edges[e.half_edges[1].idx()].origin;
|
|
(eid, e.curve, [v0, v1], false)
|
|
})
|
|
.collect();
|
|
|
|
for &new_eid in &result.new_edges {
|
|
let (_, curve_a, verts_a, del_a) = &edge_infos[new_eid.idx()];
|
|
if *del_a {
|
|
continue;
|
|
}
|
|
|
|
for (eid_b, curve_b, verts_b, del_b) in &edge_infos {
|
|
if *del_b || *eid_b == new_eid {
|
|
continue;
|
|
}
|
|
// Only check each pair once: if both are new edges, only
|
|
// check when new_eid < eid_b.
|
|
if new_edge_set.contains(&eid_b.0) && new_eid.0 >= eid_b.0 {
|
|
continue;
|
|
}
|
|
|
|
// Shared endpoint vertices
|
|
let shared: Vec<VertexId> = verts_a
|
|
.iter()
|
|
.filter(|v| verts_b.contains(v))
|
|
.copied()
|
|
.collect();
|
|
|
|
let hits = find_curve_intersections(curve_a, curve_b);
|
|
for hit in &hits {
|
|
let t1 = hit.t1;
|
|
let t2 = hit.t2.unwrap_or(0.5);
|
|
|
|
// Skip near-shared-vertex hits
|
|
let close_to_shared = shared.iter().any(|&sv| {
|
|
if sv.is_none() { return false; }
|
|
let sv_pos = self.vertex(sv).position;
|
|
(hit.point - sv_pos).hypot() < 2.0
|
|
});
|
|
if close_to_shared {
|
|
continue;
|
|
}
|
|
|
|
// Skip near-endpoint on both
|
|
if (t1 < 0.02 || t1 > 0.98) && (t2 < 0.02 || t2 > 0.98) {
|
|
continue;
|
|
}
|
|
|
|
// Check if a vertex already exists at this crossing
|
|
let has_vertex = self.vertices.iter().any(|v| {
|
|
!v.deleted && (v.position - hit.point).hypot() < 2.0
|
|
});
|
|
if has_vertex {
|
|
continue;
|
|
}
|
|
|
|
crossing_pairs.push((new_eid, t1, *eid_b, t2, hit.point));
|
|
}
|
|
}
|
|
}
|
|
|
|
if crossing_pairs.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Deduplicate near-identical crossings (same edge pair, close points)
|
|
crossing_pairs.sort_by(|a, b| {
|
|
a.0 .0.cmp(&b.0 .0)
|
|
.then(a.2 .0.cmp(&b.2 .0))
|
|
.then(a.1.partial_cmp(&b.1).unwrap())
|
|
});
|
|
crossing_pairs.dedup_by(|a, b| {
|
|
a.0 == b.0 && a.2 == b.2 && (a.4 - b.4).hypot() < 2.0
|
|
});
|
|
|
|
// Group crossings by edge so we can split from high-t to low-t.
|
|
// For each crossing, split both edges and record vertex pairs to merge.
|
|
let mut merge_pairs: Vec<(VertexId, VertexId)> = Vec::new();
|
|
|
|
// Process one crossing at a time since splits change edge geometry.
|
|
// After each split, the remaining crossings' t-values may be stale,
|
|
// so we re-detect. In practice there are very few missed crossings.
|
|
for (eid_a, t_a, eid_b, t_b, _point) in &crossing_pairs {
|
|
// Edges may have been deleted/split by a prior iteration
|
|
if self.edges[eid_a.idx()].deleted || self.edges[eid_b.idx()].deleted {
|
|
continue;
|
|
}
|
|
|
|
// Re-verify the crossing still exists on these exact edges
|
|
let curve_a = self.edges[eid_a.idx()].curve;
|
|
let curve_b = self.edges[eid_b.idx()].curve;
|
|
let hits = find_curve_intersections(&curve_a, &curve_b);
|
|
|
|
// Find the hit closest to the original (t_a, t_b)
|
|
let mut best: Option<(f64, f64)> = None;
|
|
for hit in &hits {
|
|
let ht1 = hit.t1;
|
|
let ht2 = hit.t2.unwrap_or(0.5);
|
|
// Must be interior on both edges
|
|
if ht1 < 0.01 || ht1 > 0.99 || ht2 < 0.01 || ht2 > 0.99 {
|
|
continue;
|
|
}
|
|
// Check it's near the expected point
|
|
let has_vertex = self.vertices.iter().any(|v| {
|
|
!v.deleted && (v.position - hit.point).hypot() < 2.0
|
|
});
|
|
if has_vertex {
|
|
continue;
|
|
}
|
|
if best.is_none()
|
|
|| (ht1 - t_a).abs() + (ht2 - t_b).abs()
|
|
< (best.unwrap().0 - t_a).abs() + (best.unwrap().1 - t_b).abs()
|
|
{
|
|
best = Some((ht1, ht2));
|
|
}
|
|
}
|
|
|
|
let Some((split_t_a, split_t_b)) = best else {
|
|
continue;
|
|
};
|
|
|
|
// Split both edges
|
|
let (v_a, new_edge_a) = self.split_edge(*eid_a, split_t_a);
|
|
result.split_edges.push((*eid_a, split_t_a, v_a, new_edge_a));
|
|
|
|
let (v_b, new_edge_b) = self.split_edge(*eid_b, split_t_b);
|
|
result.split_edges.push((*eid_b, split_t_b, v_b, new_edge_b));
|
|
|
|
// If snap_vertex already merged them, no need to merge again
|
|
if v_a != v_b {
|
|
merge_pairs.push((v_a, v_b));
|
|
}
|
|
}
|
|
|
|
// Merge co-located vertex pairs
|
|
let has_merges = !merge_pairs.is_empty();
|
|
for (va, vb) in &merge_pairs {
|
|
if self.vertices[va.idx()].deleted || self.vertices[vb.idx()].deleted {
|
|
continue;
|
|
}
|
|
self.merge_vertices_at_crossing(*va, *vb);
|
|
}
|
|
|
|
if has_merges {
|
|
self.reassign_faces_after_merges();
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// recompute_edge_intersections: find and split new intersections after edit
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Recompute intersections between `edge_id` and all other non-deleted edges.
|
|
///
|
|
/// After a curve edit, the moved edge may now cross other edges. This method
|
|
/// finds those intersections and splits both the edited edge and the crossed
|
|
/// edges at each intersection point (mirroring the logic in `insert_stroke`).
|
|
///
|
|
/// Returns a list of `(new_vertex, new_edge)` pairs created by splits.
|
|
pub fn recompute_edge_intersections(
|
|
&mut self,
|
|
edge_id: EdgeId,
|
|
) -> Vec<(VertexId, EdgeId)> {
|
|
use crate::curve_intersections::find_curve_intersections;
|
|
|
|
let mut created = Vec::new();
|
|
|
|
if self.edges[edge_id.idx()].deleted {
|
|
return created;
|
|
}
|
|
|
|
// Collect intersections between the edited edge and every other edge.
|
|
struct Hit {
|
|
t_on_edited: f64,
|
|
t_on_other: f64,
|
|
other_edge: EdgeId,
|
|
}
|
|
|
|
let edited_curve = self.edges[edge_id.idx()].curve;
|
|
|
|
// --- Self-intersection: split curve at midpoint, intersect the halves ---
|
|
{
|
|
let left = edited_curve.subsegment(0.0..0.5);
|
|
let right = edited_curve.subsegment(0.5..1.0);
|
|
let self_hits = find_curve_intersections(&left, &right);
|
|
|
|
// Collect valid self-intersection t-pairs (remapped to full curve)
|
|
let mut self_crossings: Vec<(f64, f64)> = Vec::new();
|
|
for inter in self_hits {
|
|
if let Some(t2) = inter.t2 {
|
|
let t_a = inter.t1 * 0.5; // left half → [0, 0.5]
|
|
let t_b = 0.5 + t2 * 0.5; // right half → [0.5, 1]
|
|
// Skip shared midpoint and near-endpoint hits
|
|
if (t_b - t_a).abs() < 0.01 || t_a < 0.001 || t_b > 0.999 {
|
|
continue;
|
|
}
|
|
self_crossings.push((t_a, t_b));
|
|
}
|
|
}
|
|
// Dedup
|
|
self_crossings.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
|
self_crossings.dedup_by(|a, b| (a.0 - b.0).abs() < 0.02);
|
|
|
|
if !self_crossings.is_empty() {
|
|
// For each self-crossing, split the edge at t_a, midpoint, and t_b.
|
|
// We process from high-t to low-t to avoid parameter shift.
|
|
// Collect all split t-values with a flag for shared-vertex pairs.
|
|
let mut self_split_ts: Vec<f64> = Vec::new();
|
|
for &(t_a, t_b) in &self_crossings {
|
|
self_split_ts.push(t_a);
|
|
self_split_ts.push((t_a + t_b) / 2.0);
|
|
self_split_ts.push(t_b);
|
|
}
|
|
self_split_ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
self_split_ts.dedup_by(|a, b| (*a - *b).abs() < 0.001);
|
|
|
|
// Split from high-t to low-t
|
|
let current_edge = edge_id;
|
|
let mut remaining_t_end = 1.0_f64;
|
|
let mut split_vertices: Vec<(f64, VertexId)> = Vec::new();
|
|
|
|
for &t in self_split_ts.iter().rev() {
|
|
let t_in_current = t / remaining_t_end;
|
|
if t_in_current < 0.001 || t_in_current > 0.999 {
|
|
continue;
|
|
}
|
|
let (new_vertex, new_edge) = self.split_edge(current_edge, t_in_current);
|
|
created.push((new_vertex, new_edge));
|
|
split_vertices.push((t, new_vertex));
|
|
remaining_t_end = t;
|
|
}
|
|
|
|
// Now merge the crossing vertex pairs. For each (t_a, t_b),
|
|
// the vertices at t_a and t_b should be the same point.
|
|
for &(t_a, t_b) in &self_crossings {
|
|
let v_a = split_vertices.iter().find(|(t, _)| (*t - t_a).abs() < 0.01);
|
|
let v_b = split_vertices.iter().find(|(t, _)| (*t - t_b).abs() < 0.01);
|
|
if let (Some(&(_, va)), Some(&(_, vb))) = (v_a, v_b) {
|
|
if !self.vertices[va.idx()].deleted && !self.vertices[vb.idx()].deleted {
|
|
self.merge_vertices_at_crossing(va, vb);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reassign faces after the self-intersection merges
|
|
self.reassign_faces_after_merges();
|
|
|
|
#[cfg(debug_assertions)]
|
|
self.validate();
|
|
|
|
return created;
|
|
}
|
|
}
|
|
|
|
let mut hits = Vec::new();
|
|
|
|
for (idx, e) in self.edges.iter().enumerate() {
|
|
if e.deleted {
|
|
continue;
|
|
}
|
|
let other_id = EdgeId(idx as u32);
|
|
if other_id == edge_id {
|
|
continue;
|
|
}
|
|
|
|
// Approximate arc lengths for scaling the near-endpoint
|
|
// threshold to a consistent spatial tolerance (pixels).
|
|
let edited_len = edited_curve.arclen(0.5).max(1.0);
|
|
let other_len = e.curve.arclen(0.5).max(1.0);
|
|
let spatial_tol = 1.0_f64; // pixels
|
|
let t1_tol = spatial_tol / edited_len;
|
|
let t2_tol = spatial_tol / other_len;
|
|
|
|
// Get endpoint vertices for shared-vertex check
|
|
let edited_v0 = self.half_edges[self.edges[edge_id.idx()].half_edges[0].idx()].origin;
|
|
let edited_v1 = self.half_edges[self.edges[edge_id.idx()].half_edges[1].idx()].origin;
|
|
let other_v0 = self.half_edges[e.half_edges[0].idx()].origin;
|
|
let other_v1 = self.half_edges[e.half_edges[1].idx()].origin;
|
|
let shared: Vec<VertexId> = [edited_v0, edited_v1]
|
|
.iter()
|
|
.filter(|v| *v == &other_v0 || *v == &other_v1)
|
|
.copied()
|
|
.collect();
|
|
|
|
let intersections = find_curve_intersections(&edited_curve, &e.curve);
|
|
for inter in intersections {
|
|
if let Some(t2) = inter.t2 {
|
|
// Skip intersections near a shared endpoint vertex
|
|
let close_to_shared = shared.iter().any(|&sv| {
|
|
let sv_pos = self.vertex(sv).position;
|
|
(inter.point - sv_pos).hypot() < 2.0
|
|
});
|
|
if close_to_shared {
|
|
continue;
|
|
}
|
|
|
|
// Skip intersections near endpoints on BOTH edges
|
|
// (shared vertex or coincident endpoints).
|
|
let near_endpoint_a = inter.t1 < t1_tol || inter.t1 > 1.0 - t1_tol;
|
|
let near_endpoint_b = t2 < t2_tol || t2 > 1.0 - t2_tol;
|
|
if near_endpoint_a && near_endpoint_b {
|
|
continue;
|
|
}
|
|
|
|
// Skip if too close to an endpoint to produce a usable
|
|
// split, but only with a tight spatial threshold.
|
|
if (inter.t1 < 0.001 || inter.t1 > 0.999)
|
|
&& (t2 < 0.001 || t2 > 0.999)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
hits.push(Hit {
|
|
t_on_edited: inter.t1,
|
|
t_on_other: t2,
|
|
other_edge: other_id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if hits.is_empty() {
|
|
return created;
|
|
}
|
|
|
|
// Group by other_edge, split each from high-t to low-t to avoid param shift.
|
|
let mut by_other: std::collections::HashMap<u32, Vec<(f64, f64)>> =
|
|
std::collections::HashMap::new();
|
|
for h in &hits {
|
|
by_other
|
|
.entry(h.other_edge.0)
|
|
.or_default()
|
|
.push((h.t_on_other, h.t_on_edited));
|
|
}
|
|
|
|
// Deduplicate within each group: the recursive intersection finder
|
|
// often returns many near-identical hits for one crossing. Keep one
|
|
// representative per cluster (using t_on_other distance < 0.1).
|
|
for splits in by_other.values_mut() {
|
|
splits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
|
splits.dedup_by(|a, b| (a.0 - b.0).abs() < 0.1);
|
|
}
|
|
|
|
// Track (t_on_edited, vertex_from_other_edge_split) pairs so we can
|
|
// later split the edited edge and merge each pair of co-located vertices.
|
|
let mut edited_edge_splits: Vec<(f64, VertexId)> = Vec::new();
|
|
|
|
for (other_raw, mut splits) in by_other {
|
|
let other_edge = EdgeId(other_raw);
|
|
// Sort descending by t_on_other
|
|
splits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
|
|
|
|
let current_edge = other_edge;
|
|
// Upper bound of current_edge in original parameter space.
|
|
// split_edge(edge, t) keeps [0, t] on current_edge, so after
|
|
// splitting at t_high the edge spans [0, t_high] (reparam to [0,1]).
|
|
let mut remaining_t_end = 1.0_f64;
|
|
|
|
for (t_on_other, t_on_edited) in splits {
|
|
let t_in_current = t_on_other / remaining_t_end;
|
|
|
|
if t_in_current < 0.001 || t_in_current > 0.999 {
|
|
continue;
|
|
}
|
|
|
|
let (new_vertex, new_edge) = self.split_edge(current_edge, t_in_current);
|
|
created.push((new_vertex, new_edge));
|
|
edited_edge_splits.push((t_on_edited, new_vertex));
|
|
|
|
// After splitting at t_in_current, current_edge is [0, t_on_other]
|
|
// in original space. Update remaining_t_end for the next iteration.
|
|
remaining_t_end = t_on_other;
|
|
let _ = new_edge;
|
|
}
|
|
}
|
|
|
|
// Now split the edited edge itself at all intersection t-values.
|
|
// Sort descending by t to avoid parameter shift.
|
|
edited_edge_splits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
|
|
// Deduplicate near-equal t values (keep the first = highest t)
|
|
edited_edge_splits.dedup_by(|a, b| (a.0 - b.0).abs() < 0.001);
|
|
|
|
let current_edge = edge_id;
|
|
let mut remaining_t_end = 1.0_f64;
|
|
|
|
// Collect crossing pairs: (vertex_on_edited_edge, vertex_on_other_edge)
|
|
let mut crossing_pairs: Vec<(VertexId, VertexId)> = Vec::new();
|
|
|
|
for (t, other_vertex) in &edited_edge_splits {
|
|
let t_in_current = *t / remaining_t_end;
|
|
|
|
if t_in_current < 0.001 || t_in_current > 0.999 {
|
|
continue;
|
|
}
|
|
|
|
let (new_vertex, new_edge) = self.split_edge(current_edge, t_in_current);
|
|
created.push((new_vertex, new_edge));
|
|
crossing_pairs.push((new_vertex, *other_vertex));
|
|
remaining_t_end = *t;
|
|
let _ = new_edge;
|
|
}
|
|
|
|
// Post-process: merge co-located vertex pairs at each crossing point.
|
|
// Do all vertex merges first (topology only), then reassign faces once.
|
|
let has_merges = !crossing_pairs.is_empty();
|
|
for (v_edited, v_other) in &crossing_pairs {
|
|
if self.vertices[v_edited.idx()].deleted || self.vertices[v_other.idx()].deleted {
|
|
continue;
|
|
}
|
|
self.merge_vertices_at_crossing(*v_edited, *v_other);
|
|
}
|
|
|
|
// Now that all merges are done, walk all cycles and assign faces.
|
|
if has_merges {
|
|
self.reassign_faces_after_merges();
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
self.validate();
|
|
|
|
created
|
|
}
|
|
|
|
/// Compute the outgoing angle (in radians, via atan2) of a half-edge at its
|
|
/// origin vertex. Used to sort half-edges CCW around a vertex.
|
|
fn outgoing_angle(&self, he: HalfEdgeId) -> f64 {
|
|
let he_data = self.half_edge(he);
|
|
let edge_data = self.edge(he_data.edge);
|
|
let is_forward = edge_data.half_edges[0] == he;
|
|
|
|
let (from, to, fallback) = if is_forward {
|
|
// Forward half-edge: direction from curve.p0 → curve.p1 (fallback curve.p3)
|
|
(edge_data.curve.p0, edge_data.curve.p1, edge_data.curve.p3)
|
|
} else {
|
|
// Backward half-edge: direction from curve.p3 → curve.p2 (fallback curve.p0)
|
|
(edge_data.curve.p3, edge_data.curve.p2, edge_data.curve.p0)
|
|
};
|
|
|
|
let dx = to.x - from.x;
|
|
let dy = to.y - from.y;
|
|
if dx * dx + dy * dy > 1e-18 {
|
|
dy.atan2(dx)
|
|
} else {
|
|
// Degenerate: control point coincides with endpoint, use far endpoint
|
|
let dx = fallback.x - from.x;
|
|
let dy = fallback.y - from.y;
|
|
dy.atan2(dx)
|
|
}
|
|
}
|
|
|
|
/// Merge two co-located vertices at a crossing point and relink half-edges.
|
|
///
|
|
/// After `split_edge()` creates two separate vertices at the same crossing,
|
|
/// this merges them into one, sorts the (now valence-4) outgoing half-edges
|
|
/// by angle, and relinks `next`/`prev` using the standard DCEL vertex rule.
|
|
///
|
|
/// Face assignment is NOT done here — call `reassign_faces_after_merges()`
|
|
/// once after all merges are complete.
|
|
fn merge_vertices_at_crossing(
|
|
&mut self,
|
|
v_keep: VertexId,
|
|
v_remove: VertexId,
|
|
) {
|
|
// If snap_vertex already merged these during split_edge, they're the
|
|
// same vertex. Proceeding would call free_vertex on a live vertex,
|
|
// putting it on the free list while edges still reference it.
|
|
if v_keep == v_remove {
|
|
return;
|
|
}
|
|
|
|
let keep_pos = self.vertices[v_keep.idx()].position;
|
|
|
|
// Re-home half-edges from v_remove → v_keep, and fix curve endpoints
|
|
for i in 0..self.half_edges.len() {
|
|
if self.half_edges[i].deleted {
|
|
continue;
|
|
}
|
|
if self.half_edges[i].origin == v_remove {
|
|
self.half_edges[i].origin = v_keep;
|
|
|
|
// Fix the curve endpoint so it matches v_keep's position.
|
|
// A half-edge's origin is the start of that half-edge.
|
|
// The forward half-edge (index 0) of an edge starts at p0.
|
|
// The backward half-edge (index 1) starts at p3.
|
|
let edge_id = self.half_edges[i].edge;
|
|
let edge = &self.edges[edge_id.idx()];
|
|
let he_id = HalfEdgeId(i as u32);
|
|
if edge.half_edges[0] == he_id {
|
|
self.edges[edge_id.idx()].curve.p0 = keep_pos;
|
|
} else if edge.half_edges[1] == he_id {
|
|
self.edges[edge_id.idx()].curve.p3 = keep_pos;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect & sort outgoing half-edges by angle (CCW).
|
|
// We can't use vertex_outgoing() because the next/prev links
|
|
// aren't correct for the merged vertex yet.
|
|
let mut outgoing: Vec<HalfEdgeId> = Vec::new();
|
|
for i in 0..self.half_edges.len() {
|
|
if self.half_edges[i].deleted {
|
|
continue;
|
|
}
|
|
if self.half_edges[i].origin == v_keep {
|
|
outgoing.push(HalfEdgeId(i as u32));
|
|
}
|
|
}
|
|
outgoing.sort_by(|&a, &b| {
|
|
let angle_a = self.outgoing_angle(a);
|
|
let angle_b = self.outgoing_angle(b);
|
|
angle_a.partial_cmp(&angle_b).unwrap()
|
|
});
|
|
|
|
let n = outgoing.len();
|
|
if n < 2 {
|
|
self.vertices[v_keep.idx()].outgoing = if n == 1 {
|
|
outgoing[0]
|
|
} else {
|
|
HalfEdgeId::NONE
|
|
};
|
|
self.free_vertex(v_remove);
|
|
return;
|
|
}
|
|
|
|
// Relink next/prev at vertex using the standard DCEL rule:
|
|
// twin(outgoing[i]).next = outgoing[(i+1) % N]
|
|
for i in 0..n {
|
|
let twin_i = self.half_edges[outgoing[i].idx()].twin;
|
|
let next_out = outgoing[(i + 1) % n];
|
|
self.half_edges[twin_i.idx()].next = next_out;
|
|
self.half_edges[next_out.idx()].prev = twin_i;
|
|
}
|
|
|
|
// Cleanup vertex
|
|
self.vertices[v_keep.idx()].outgoing = outgoing[0];
|
|
self.free_vertex(v_remove);
|
|
}
|
|
|
|
/// After merging vertices at crossing points, walk all face cycles and
|
|
/// reassign faces. This must be called once after all merges are done,
|
|
/// because individual merges can break cycles created by earlier merges.
|
|
fn reassign_faces_after_merges(&mut self) {
|
|
let mut visited = vec![false; self.half_edges.len()];
|
|
let mut cycles: Vec<(HalfEdgeId, Vec<HalfEdgeId>)> = Vec::new();
|
|
|
|
// Discover all face cycles by walking from every unvisited half-edge.
|
|
for i in 0..self.half_edges.len() {
|
|
if self.half_edges[i].deleted || visited[i] {
|
|
continue;
|
|
}
|
|
let start_he = HalfEdgeId(i as u32);
|
|
let mut cycle_hes: Vec<HalfEdgeId> = Vec::new();
|
|
let mut cur = start_he;
|
|
loop {
|
|
if visited[cur.idx()] {
|
|
break;
|
|
}
|
|
visited[cur.idx()] = true;
|
|
cycle_hes.push(cur);
|
|
cur = self.half_edges[cur.idx()].next;
|
|
if cur == start_he {
|
|
break;
|
|
}
|
|
if cycle_hes.len() > self.half_edges.len() {
|
|
debug_assert!(false, "infinite loop in face reassignment cycle walk");
|
|
break;
|
|
}
|
|
}
|
|
if !cycle_hes.is_empty() {
|
|
cycles.push((start_he, cycle_hes));
|
|
}
|
|
}
|
|
|
|
// Collect old face assignments from half-edges (before reassignment).
|
|
// Each cycle votes on which old face it belongs to.
|
|
struct CycleInfo {
|
|
start_he: HalfEdgeId,
|
|
half_edges: Vec<HalfEdgeId>,
|
|
face_votes: std::collections::HashMap<u32, usize>,
|
|
}
|
|
let cycle_infos: Vec<CycleInfo> = cycles
|
|
.into_iter()
|
|
.map(|(start_he, hes)| {
|
|
let mut face_votes: std::collections::HashMap<u32, usize> =
|
|
std::collections::HashMap::new();
|
|
for &he in &hes {
|
|
let f = self.half_edges[he.idx()].face;
|
|
if !f.is_none() {
|
|
*face_votes.entry(f.0).or_insert(0) += 1;
|
|
}
|
|
}
|
|
CycleInfo {
|
|
start_he,
|
|
half_edges: hes,
|
|
face_votes,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Collect all old faces referenced.
|
|
let mut all_old_faces: std::collections::HashSet<u32> =
|
|
std::collections::HashSet::new();
|
|
for c in &cycle_infos {
|
|
for &f in c.face_votes.keys() {
|
|
all_old_faces.insert(f);
|
|
}
|
|
}
|
|
|
|
// For each old face, assign it to the cycle with the most votes.
|
|
let mut cycle_face_assignment: Vec<Option<FaceId>> =
|
|
vec![None; cycle_infos.len()];
|
|
|
|
for &old_face_raw in &all_old_faces {
|
|
let mut best_idx: Option<usize> = None;
|
|
let mut best_count: usize = 0;
|
|
for (i, c) in cycle_infos.iter().enumerate() {
|
|
if cycle_face_assignment[i].is_some() {
|
|
continue;
|
|
}
|
|
let count = c.face_votes.get(&old_face_raw).copied().unwrap_or(0);
|
|
if count > best_count {
|
|
best_count = count;
|
|
best_idx = Some(i);
|
|
}
|
|
}
|
|
if let Some(idx) = best_idx {
|
|
cycle_face_assignment[idx] = Some(FaceId(old_face_raw));
|
|
}
|
|
}
|
|
|
|
// Any cycle without an assigned face gets a new one, inheriting
|
|
// fill properties from the old face it voted for most.
|
|
for i in 0..cycle_infos.len() {
|
|
if cycle_face_assignment[i].is_none() {
|
|
// Determine which face to inherit fill from. Check both
|
|
// the cycle's own old face votes AND the adjacent faces
|
|
// (via twin half-edges), because at crossings the inside/
|
|
// outside flips and the cycle's own votes may point to F0.
|
|
let mut fill_candidates: std::collections::HashMap<u32, usize> =
|
|
std::collections::HashMap::new();
|
|
// Own votes
|
|
for (&face_raw, &count) in &cycle_infos[i].face_votes {
|
|
*fill_candidates.entry(face_raw).or_insert(0) += count;
|
|
}
|
|
// Adjacent faces (twins)
|
|
for &he in &cycle_infos[i].half_edges {
|
|
let twin = self.half_edges[he.idx()].twin;
|
|
let twin_face = self.half_edges[twin.idx()].face;
|
|
if !twin_face.is_none() {
|
|
*fill_candidates.entry(twin_face.0).or_insert(0) += 1;
|
|
}
|
|
}
|
|
// Pick the best non-F0 candidate (F0 is unbounded, no fill).
|
|
let parent_face = fill_candidates
|
|
.iter()
|
|
.filter(|(&face_raw, _)| face_raw != 0)
|
|
.max_by_key(|&(_, &count)| count)
|
|
.map(|(&face_raw, _)| FaceId(face_raw));
|
|
|
|
let f = self.alloc_face();
|
|
// Copy fill properties from the parent face.
|
|
if let Some(parent) = parent_face {
|
|
self.faces[f.idx()].fill_color =
|
|
self.faces[parent.idx()].fill_color.clone();
|
|
self.faces[f.idx()].image_fill =
|
|
self.faces[parent.idx()].image_fill;
|
|
self.faces[f.idx()].fill_rule =
|
|
self.faces[parent.idx()].fill_rule;
|
|
}
|
|
cycle_face_assignment[i] = Some(f);
|
|
}
|
|
}
|
|
|
|
// Apply assignments.
|
|
for (i, cycle) in cycle_infos.iter().enumerate() {
|
|
let face = cycle_face_assignment[i].unwrap();
|
|
for &he in &cycle.half_edges {
|
|
self.half_edges[he.idx()].face = face;
|
|
}
|
|
if face.0 == 0 {
|
|
self.faces[0]
|
|
.inner_half_edges
|
|
.retain(|h| !cycle.half_edges.contains(h));
|
|
self.faces[0].inner_half_edges.push(cycle.start_he);
|
|
} else {
|
|
self.faces[face.idx()].outer_half_edge = cycle.start_he;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record a paint bucket click point for debug test generation.
|
|
/// Call this before `find_face_containing_point` when the paint bucket is used.
|
|
pub fn record_paint_point(&mut self, point: Point) {
|
|
if let Some(ref mut rec) = self.debug_recorder {
|
|
eprintln!("[DCEL_RECORD] paint_point: ({:.1}, {:.1}) (total points: {})",
|
|
point.x, point.y, rec.paint_points.len() + 1);
|
|
rec.record_paint(point);
|
|
}
|
|
}
|
|
|
|
/// Find which face contains a given point.
|
|
///
|
|
/// Returns the smallest-area face whose boundary encloses the point.
|
|
/// This handles the case where a large "exterior boundary" face encloses
|
|
/// smaller interior faces — we want the innermost one.
|
|
/// Returns FaceId(0) (unbounded) if no bounded face contains the point.
|
|
pub fn find_face_containing_point(&self, point: Point) -> FaceId {
|
|
use kurbo::Shape;
|
|
let mut best_face = FaceId(0);
|
|
let mut best_area = f64::MAX;
|
|
|
|
for (i, face) in self.faces.iter().enumerate() {
|
|
if face.deleted || i == 0 {
|
|
continue;
|
|
}
|
|
if face.outer_half_edge.is_none() {
|
|
continue;
|
|
}
|
|
// Use stripped cycle to avoid bloated winding/area from spur
|
|
// edges and vertex-revisit peninsulas.
|
|
let path = self.face_to_bezpath_stripped(FaceId(i as u32));
|
|
if path.winding(point) != 0 {
|
|
let area = path.area().abs();
|
|
if area < best_area {
|
|
best_area = area;
|
|
best_face = FaceId(i as u32);
|
|
}
|
|
}
|
|
}
|
|
best_face
|
|
}
|
|
}
|
|
|
|
/// Extract a subsegment of a cubic bezier for parameter range [t0, t1].
|
|
fn subsegment_cubic(c: CubicBez, t0: f64, t1: f64) -> CubicBez {
|
|
if (t0 - 0.0).abs() < 1e-10 && (t1 - 1.0).abs() < 1e-10 {
|
|
return c;
|
|
}
|
|
// Split at t1 first, take the first part, then split that at t0/t1
|
|
if (t0 - 0.0).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
|
|
}
|
|
}
|
|
|
|
/// Get the midpoint of a cubic bezier.
|
|
fn midpoint_of_cubic(c: &CubicBez) -> Point {
|
|
c.eval(0.5)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bezier subdivision
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Split a cubic bezier at parameter t using de Casteljau's algorithm.
|
|
/// Returns (first_half, second_half).
|
|
pub fn subdivide_cubic(c: CubicBez, t: f64) -> (CubicBez, CubicBez) {
|
|
// Level 1
|
|
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);
|
|
// Level 2
|
|
let p012 = lerp_point(p01, p12, t);
|
|
let p123 = lerp_point(p12, p23, t);
|
|
// Level 3
|
|
let p0123 = lerp_point(p012, p123, t);
|
|
|
|
(
|
|
CubicBez::new(c.p0, p01, p012, p0123),
|
|
CubicBez::new(p0123, p123, p23, c.p3),
|
|
)
|
|
}
|
|
|
|
#[inline]
|
|
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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BezPath → cubic segments conversion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Convert a `BezPath` into a list of sub-paths, each a `Vec<CubicBez>`.
|
|
///
|
|
/// - `MoveTo` starts a new sub-path.
|
|
/// - `LineTo` is promoted to a degenerate cubic.
|
|
/// - `QuadTo` is degree-elevated to cubic.
|
|
/// - `CurveTo` is passed through directly.
|
|
/// - `ClosePath` emits a closing line segment if the current point differs
|
|
/// from the sub-path start.
|
|
pub fn bezpath_to_cubic_segments(path: &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) => {
|
|
// Degree-elevate: CP1 = P0 + 2/3*(Q1-P0), CP2 = P2 + 2/3*(Q1-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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Render all filled faces of a DCEL to a tiny-skia pixmap.
|
|
/// Returns the pixmap so callers can check pixel values.
|
|
fn render_dcel_fills(dcel: &Dcel, width: u32, height: u32) -> tiny_skia::Pixmap {
|
|
let mut pixmap = tiny_skia::Pixmap::new(width, height).unwrap();
|
|
|
|
for (i, face) in dcel.faces.iter().enumerate() {
|
|
if face.deleted || i == 0 { continue; }
|
|
if face.fill_color.is_none() { continue; }
|
|
if face.outer_half_edge.is_none() { continue; }
|
|
|
|
let fid = FaceId(i as u32);
|
|
let mut bez = dcel.face_to_bezpath_stripped(fid);
|
|
|
|
// Subtract any other face that is geometrically inside this face
|
|
// but topologically disconnected (no shared edges). These are
|
|
// concentric/nested cycles that should appear as holes.
|
|
let outer_path = dcel.face_to_bezpath_stripped(fid);
|
|
let outer_cycle = dcel.face_boundary(fid);
|
|
let outer_edges: std::collections::HashSet<EdgeId> = outer_cycle
|
|
.iter()
|
|
.map(|&he| dcel.half_edge(he).edge)
|
|
.collect();
|
|
for (j, other) in dcel.faces.iter().enumerate() {
|
|
if j == i || j == 0 || other.deleted || other.outer_half_edge.is_none() {
|
|
continue;
|
|
}
|
|
let other_cycle = dcel.face_boundary(FaceId(j as u32));
|
|
if other_cycle.is_empty() { continue; }
|
|
// Skip if the two faces share any edge (they're adjacent, not nested)
|
|
let shares_edge = other_cycle.iter().any(|&he| {
|
|
outer_edges.contains(&dcel.half_edge(he).edge)
|
|
});
|
|
if shares_edge { continue; }
|
|
// Check if a point on the other face's boundary is inside this face
|
|
let sample_he = other_cycle[0];
|
|
let sample_pt = dcel.edge(dcel.half_edge(sample_he).edge).curve.eval(0.5);
|
|
if kurbo::Shape::winding(&outer_path, sample_pt) != 0 {
|
|
let hole = dcel.face_to_bezpath_stripped(FaceId(j as u32));
|
|
for el in hole.elements() {
|
|
bez.push(*el);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert kurbo BezPath to tiny-skia PathBuilder
|
|
let mut pb = tiny_skia::PathBuilder::new();
|
|
for el in bez.elements() {
|
|
match el {
|
|
kurbo::PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
|
|
kurbo::PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
|
|
kurbo::PathEl::CurveTo(p1, p2, p3) => {
|
|
pb.cubic_to(
|
|
p1.x as f32, p1.y as f32,
|
|
p2.x as f32, p2.y as f32,
|
|
p3.x as f32, p3.y as f32,
|
|
);
|
|
}
|
|
kurbo::PathEl::QuadTo(p1, p2) => {
|
|
pb.quad_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32);
|
|
}
|
|
kurbo::PathEl::ClosePath => pb.close(),
|
|
}
|
|
}
|
|
|
|
if let Some(path) = pb.finish() {
|
|
let paint = tiny_skia::Paint {
|
|
shader: tiny_skia::Shader::SolidColor(
|
|
tiny_skia::Color::from_rgba8(0, 0, 255, 255),
|
|
),
|
|
anti_alias: false,
|
|
..Default::default()
|
|
};
|
|
pixmap.fill_path(
|
|
&path,
|
|
&paint,
|
|
tiny_skia::FillRule::EvenOdd,
|
|
tiny_skia::Transform::identity(),
|
|
None,
|
|
);
|
|
}
|
|
}
|
|
|
|
pixmap
|
|
}
|
|
|
|
/// Check that a pixel at (x, y) is NOT filled (is transparent/background).
|
|
fn assert_pixel_unfilled(pixmap: &tiny_skia::Pixmap, x: f64, y: f64, msg: &str) {
|
|
let px = x.round() as u32;
|
|
let py = y.round() as u32;
|
|
if px >= pixmap.width() || py >= pixmap.height() {
|
|
panic!("{msg}: point ({x:.1}, {y:.1}) is outside the pixmap");
|
|
}
|
|
let pixel = pixmap.pixel(px, py).unwrap();
|
|
assert!(
|
|
pixel.alpha() == 0,
|
|
"{msg}: pixel at ({x:.1}, {y:.1}) is already filled (rgba={},{},{},{})",
|
|
pixel.red(), pixel.green(), pixel.blue(), pixel.alpha(),
|
|
);
|
|
}
|
|
|
|
/// Simulate paint bucket clicks: for each point, assert the pixel is unfilled,
|
|
/// find the face, fill it, re-render, and continue.
|
|
fn assert_paint_sequence(dcel: &mut Dcel, paint_points: &[Point], width: u32, height: u32) {
|
|
for (i, &pt) in paint_points.iter().enumerate() {
|
|
// Render current state and check this pixel is unfilled
|
|
let pixmap = render_dcel_fills(dcel, width, height);
|
|
assert_pixel_unfilled(
|
|
&pixmap, pt.x, pt.y,
|
|
&format!("paint point {i} at ({:.1}, {:.1})", pt.x, pt.y),
|
|
);
|
|
|
|
// Find and fill the face
|
|
let face = dcel.find_face_containing_point(pt);
|
|
assert!(
|
|
face.0 != 0,
|
|
"paint point {i} at ({:.1}, {:.1}) hit unbounded face",
|
|
pt.x, pt.y,
|
|
);
|
|
dcel.face_mut(face).fill_color = Some(ShapeColor::new(0, 0, 255, 255));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_dcel_has_unbounded_face() {
|
|
let dcel = Dcel::new();
|
|
assert_eq!(dcel.faces.len(), 1);
|
|
assert!(!dcel.faces[0].deleted);
|
|
assert!(dcel.faces[0].outer_half_edge.is_none());
|
|
assert!(dcel.faces[0].fill_color.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_alloc_vertex() {
|
|
let mut dcel = Dcel::new();
|
|
let v = dcel.alloc_vertex(Point::new(1.0, 2.0));
|
|
assert_eq!(v.0, 0);
|
|
assert_eq!(dcel.vertex(v).position, Point::new(1.0, 2.0));
|
|
assert!(dcel.vertex(v).outgoing.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_free_and_reuse_vertex() {
|
|
let mut dcel = Dcel::new();
|
|
let v0 = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
|
let v1 = dcel.alloc_vertex(Point::new(1.0, 1.0));
|
|
dcel.free_vertex(v0);
|
|
let v2 = dcel.alloc_vertex(Point::new(2.0, 2.0));
|
|
// Should reuse slot 0
|
|
assert_eq!(v2.0, 0);
|
|
assert_eq!(dcel.vertex(v2).position, Point::new(2.0, 2.0));
|
|
assert!(!dcel.vertex(v2).deleted);
|
|
let _ = v1; // suppress unused warning
|
|
}
|
|
|
|
#[test]
|
|
fn test_snap_vertex() {
|
|
let mut dcel = Dcel::new();
|
|
let v = dcel.alloc_vertex(Point::new(10.0, 10.0));
|
|
// Exact match
|
|
assert_eq!(dcel.snap_vertex(Point::new(10.0, 10.0), 0.5), Some(v));
|
|
// Within epsilon
|
|
assert_eq!(dcel.snap_vertex(Point::new(10.3, 10.0), 0.5), Some(v));
|
|
// Outside epsilon
|
|
assert_eq!(dcel.snap_vertex(Point::new(11.0, 10.0), 0.5), None);
|
|
}
|
|
|
|
fn line_curve(p0: Point, p1: Point) -> CubicBez {
|
|
// A straight-line cubic bezier
|
|
let d = p1 - p0;
|
|
CubicBez::new(
|
|
p0,
|
|
Point::new(p0.x + d.x / 3.0, p0.y + d.y / 3.0),
|
|
Point::new(p0.x + 2.0 * d.x / 3.0, p0.y + 2.0 * d.y / 3.0),
|
|
p1,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_first_edge_into_unbounded_face() {
|
|
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 (edge_id, _) = dcel.insert_edge(
|
|
v1,
|
|
v2,
|
|
FaceId(0),
|
|
line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)),
|
|
);
|
|
|
|
assert!(!dcel.edge(edge_id).deleted);
|
|
assert_eq!(dcel.edges.len(), 1);
|
|
// Both half-edges should exist
|
|
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
|
|
assert!(!he_fwd.is_none());
|
|
assert!(!he_bwd.is_none());
|
|
assert_eq!(dcel.half_edge(he_fwd).origin, v1);
|
|
assert_eq!(dcel.half_edge(he_bwd).origin, v2);
|
|
// Twins
|
|
assert_eq!(dcel.half_edge(he_fwd).twin, he_bwd);
|
|
assert_eq!(dcel.half_edge(he_bwd).twin, he_fwd);
|
|
// Next/prev form a 2-cycle
|
|
assert_eq!(dcel.half_edge(he_fwd).next, he_bwd);
|
|
assert_eq!(dcel.half_edge(he_bwd).next, he_fwd);
|
|
|
|
dcel.validate();
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_triangle_splits_face() {
|
|
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));
|
|
|
|
// Insert three edges to form a triangle
|
|
let (e1, _) = dcel.insert_edge(
|
|
v1,
|
|
v2,
|
|
FaceId(0),
|
|
line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)),
|
|
);
|
|
|
|
// v2 → v3: v2 has an outgoing edge, v3 is isolated → spur case
|
|
let (e2, _) = dcel.insert_edge(
|
|
v2,
|
|
v3,
|
|
FaceId(0),
|
|
line_curve(Point::new(10.0, 0.0), Point::new(5.0, 10.0)),
|
|
);
|
|
|
|
// v3 → v1: both have outgoing edges on face 0 → face split
|
|
let (e3, new_face) = dcel.insert_edge(
|
|
v3,
|
|
v1,
|
|
FaceId(0),
|
|
line_curve(Point::new(5.0, 10.0), Point::new(0.0, 0.0)),
|
|
);
|
|
|
|
// Should have created a new face (the triangle interior)
|
|
assert!(new_face.0 > 0, "should create a new face for the triangle interior");
|
|
|
|
// Validate all invariants
|
|
dcel.validate();
|
|
|
|
// Count non-deleted faces (should be 2: unbounded + triangle)
|
|
let live_faces = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
assert_eq!(live_faces, 2, "expected 2 faces (unbounded + triangle)");
|
|
|
|
let _ = (e1, e2, e3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_split_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 (edge_id, _) = dcel.insert_edge(
|
|
v1,
|
|
v2,
|
|
FaceId(0),
|
|
line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)),
|
|
);
|
|
|
|
let (new_vertex, new_edge) = dcel.split_edge(edge_id, 0.5);
|
|
|
|
// New vertex should be at midpoint
|
|
let pos = dcel.vertex(new_vertex).position;
|
|
assert!((pos.x - 5.0).abs() < 0.01);
|
|
assert!((pos.y - 0.0).abs() < 0.01);
|
|
|
|
// Should have 2 edges now
|
|
let live_edges = dcel.edges.iter().filter(|e| !e.deleted).count();
|
|
assert_eq!(live_edges, 2);
|
|
|
|
// Original edge curve.p3 should be at midpoint
|
|
assert!((dcel.edge(edge_id).curve.p3.x - 5.0).abs() < 0.01);
|
|
// New edge curve.p0 should be at midpoint
|
|
assert!((dcel.edge(new_edge).curve.p0.x - 5.0).abs() < 0.01);
|
|
// New edge curve.p3 should be at original endpoint
|
|
assert!((dcel.edge(new_edge).curve.p3.x - 10.0).abs() < 0.01);
|
|
|
|
dcel.validate();
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_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 (edge_id, _) = dcel.insert_edge(
|
|
v1,
|
|
v2,
|
|
FaceId(0),
|
|
line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)),
|
|
);
|
|
|
|
let surviving = dcel.remove_edge(edge_id);
|
|
assert_eq!(surviving, FaceId(0));
|
|
|
|
// Edge should be deleted
|
|
assert!(dcel.edge(edge_id).deleted);
|
|
|
|
// Vertices should be isolated
|
|
assert!(dcel.vertex(v1).outgoing.is_none());
|
|
assert!(dcel.vertex(v2).outgoing.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_subdivide_cubic_midpoint() {
|
|
let c = CubicBez::new(
|
|
Point::new(0.0, 0.0),
|
|
Point::new(1.0, 2.0),
|
|
Point::new(3.0, 2.0),
|
|
Point::new(4.0, 0.0),
|
|
);
|
|
let (a, b) = subdivide_cubic(c, 0.5);
|
|
// Endpoints should match
|
|
assert_eq!(a.p0, c.p0);
|
|
assert_eq!(b.p3, c.p3);
|
|
// Junction should match
|
|
assert!((a.p3.x - b.p0.x).abs() < 1e-10);
|
|
assert!((a.p3.y - b.p0.y).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_face_to_bezpath() {
|
|
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));
|
|
|
|
// Build triangle
|
|
dcel.insert_edge(v1, v2, FaceId(0), line_curve(Point::new(0.0, 0.0), Point::new(10.0, 0.0)));
|
|
dcel.insert_edge(v2, v3, FaceId(0), line_curve(Point::new(10.0, 0.0), Point::new(5.0, 10.0)));
|
|
let (_, new_face) = dcel.insert_edge(v3, v1, FaceId(0), line_curve(Point::new(5.0, 10.0), Point::new(0.0, 0.0)));
|
|
|
|
dcel.validate();
|
|
|
|
// The new face should produce a non-empty BezPath
|
|
let path = dcel.face_to_bezpath(new_face);
|
|
assert!(!path.elements().is_empty());
|
|
}
|
|
|
|
/// Rectangle ABCD, drag midpoint of AB across BC creating crossing X.
|
|
/// Two polygons should result: AXCD and a bigon XB (the "XMB" region).
|
|
#[test]
|
|
fn test_crossing_creates_two_faces() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Rectangle at pixel scale: A=(0,100), B=(100,100), C=(100,0), D=(0,0)
|
|
let a = dcel.alloc_vertex(Point::new(0.0, 100.0));
|
|
let b = dcel.alloc_vertex(Point::new(100.0, 100.0));
|
|
let c = dcel.alloc_vertex(Point::new(100.0, 0.0));
|
|
let d = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
|
|
|
// Build rectangle edges AB, BC, CD, DA
|
|
let (e_ab, _) = dcel.insert_edge(
|
|
a, b, FaceId(0),
|
|
line_curve(Point::new(0.0, 100.0), Point::new(100.0, 100.0)),
|
|
);
|
|
let (e_bc, _) = dcel.insert_edge(
|
|
b, c, FaceId(0),
|
|
line_curve(Point::new(100.0, 100.0), Point::new(100.0, 0.0)),
|
|
);
|
|
let (e_cd, _) = dcel.insert_edge(
|
|
c, d, FaceId(0),
|
|
line_curve(Point::new(100.0, 0.0), Point::new(0.0, 0.0)),
|
|
);
|
|
let (e_da, _) = dcel.insert_edge(
|
|
d, a, FaceId(0),
|
|
line_curve(Point::new(0.0, 0.0), Point::new(0.0, 100.0)),
|
|
);
|
|
|
|
dcel.validate();
|
|
|
|
let faces_before = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
|
|
// Simulate dragging midpoint M of AB to (200, 50).
|
|
// Control points at (180, 50) and (220, 50) — same as user's
|
|
// coordinates scaled by 100.
|
|
let new_ab_curve = CubicBez::new(
|
|
Point::new(0.0, 100.0),
|
|
Point::new(180.0, 50.0),
|
|
Point::new(220.0, 50.0),
|
|
Point::new(100.0, 100.0),
|
|
);
|
|
dcel.edges[e_ab.idx()].curve = new_ab_curve;
|
|
|
|
// Recompute intersections — this should split AB and BC at the crossing,
|
|
// merge the co-located vertices, and create the new face.
|
|
let created = dcel.recompute_edge_intersections(e_ab);
|
|
|
|
// Should have created vertices and edges from the splits
|
|
assert!(
|
|
!created.is_empty(),
|
|
"recompute_edge_intersections should have found the crossing"
|
|
);
|
|
|
|
dcel.validate();
|
|
|
|
let faces_after = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
assert!(
|
|
faces_after > faces_before,
|
|
"a new face should have been created for the XMB region \
|
|
(before: {}, after: {})",
|
|
faces_before,
|
|
faces_after
|
|
);
|
|
|
|
let _ = (e_bc, e_cd, e_da);
|
|
}
|
|
|
|
#[test]
|
|
fn test_two_crossings_creates_three_faces() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Rectangle at pixel scale: A=(0,100), B=(100,100), C=(100,0), D=(0,0)
|
|
let a = dcel.alloc_vertex(Point::new(0.0, 100.0));
|
|
let b = dcel.alloc_vertex(Point::new(100.0, 100.0));
|
|
let c = dcel.alloc_vertex(Point::new(100.0, 0.0));
|
|
let d = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
|
|
|
let (e_ab, _) = dcel.insert_edge(
|
|
a, b, FaceId(0),
|
|
line_curve(Point::new(0.0, 100.0), Point::new(100.0, 100.0)),
|
|
);
|
|
let (e_bc, _) = dcel.insert_edge(
|
|
b, c, FaceId(0),
|
|
line_curve(Point::new(100.0, 100.0), Point::new(100.0, 0.0)),
|
|
);
|
|
let (e_cd, _) = dcel.insert_edge(
|
|
c, d, FaceId(0),
|
|
line_curve(Point::new(100.0, 0.0), Point::new(0.0, 0.0)),
|
|
);
|
|
let (e_da, _) = dcel.insert_edge(
|
|
d, a, FaceId(0),
|
|
line_curve(Point::new(0.0, 0.0), Point::new(0.0, 100.0)),
|
|
);
|
|
|
|
dcel.validate();
|
|
let faces_before = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
|
|
// Drag M through CD: curve from A to B that dips below y=0,
|
|
// crossing CD (y=0 line) twice.
|
|
let new_ab_curve = CubicBez::new(
|
|
Point::new(0.0, 100.0),
|
|
Point::new(30.0, -80.0),
|
|
Point::new(70.0, -80.0),
|
|
Point::new(100.0, 100.0),
|
|
);
|
|
dcel.edges[e_ab.idx()].curve = new_ab_curve;
|
|
|
|
let created = dcel.recompute_edge_intersections(e_ab);
|
|
|
|
eprintln!("created: {:?}", created);
|
|
eprintln!("vertices: {}", dcel.vertices.iter().filter(|v| !v.deleted).count());
|
|
eprintln!("edges: {}", dcel.edges.iter().filter(|e| !e.deleted).count());
|
|
eprintln!("faces (non-deleted):");
|
|
for (i, f) in dcel.faces.iter().enumerate() {
|
|
if !f.deleted {
|
|
let cycle_len = if !f.outer_half_edge.is_none() {
|
|
dcel.walk_cycle(f.outer_half_edge).len()
|
|
} else {
|
|
0
|
|
};
|
|
eprintln!(" F{}: outer={:?} cycle_len={}", i, f.outer_half_edge, cycle_len);
|
|
}
|
|
}
|
|
|
|
// Should have 4 splits (2 on CD, 2 on AB)
|
|
assert!(
|
|
created.len() >= 4,
|
|
"expected at least 4 splits, got {}",
|
|
created.len()
|
|
);
|
|
|
|
dcel.validate();
|
|
|
|
let faces_after = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
// Before: 2 faces (interior + exterior). After: 4 (AX1D, X1X2M, X2BC + exterior)
|
|
assert!(
|
|
faces_after >= faces_before + 2,
|
|
"should have at least 2 new faces (before: {}, after: {})",
|
|
faces_before,
|
|
faces_after
|
|
);
|
|
|
|
let _ = (e_bc, e_cd, e_da);
|
|
}
|
|
|
|
#[test]
|
|
fn test_single_segment_self_intersection() {
|
|
// A single cubic bezier that loops back on itself.
|
|
// Control points (300,150) and (-100,150) are far apart and on opposite
|
|
// sides of the chord, forcing the curve to reverse in X and cross itself
|
|
// near t≈0.175 and t≈0.825.
|
|
let mut dcel = Dcel::new();
|
|
|
|
let seg = CubicBez::new(
|
|
Point::new(0.0, 0.0),
|
|
Point::new(300.0, 150.0),
|
|
Point::new(-100.0, 150.0),
|
|
Point::new(200.0, 0.0),
|
|
);
|
|
|
|
let result = dcel.insert_stroke(&[seg], None, None, 5.0);
|
|
|
|
eprintln!("new_vertices: {:?}", result.new_vertices);
|
|
eprintln!("new_edges: {:?}", result.new_edges);
|
|
eprintln!("new_faces: {:?}", result.new_faces);
|
|
let live_faces = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
eprintln!("total live faces: {}", live_faces);
|
|
|
|
// The self-intersection splits the single segment into 3 sub-edges,
|
|
// creating 1 enclosed loop → at least 2 faces (loop + unbounded).
|
|
assert!(
|
|
live_faces >= 2,
|
|
"expected at least 2 faces (1 loop + unbounded), got {}",
|
|
live_faces,
|
|
);
|
|
assert!(
|
|
result.new_edges.len() >= 3,
|
|
"expected at least 3 sub-edges from self-intersecting segment, got {}",
|
|
result.new_edges.len(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_adjacent_segments_crossing() {
|
|
// Two adjacent segments that cross each other.
|
|
// seg0 is an S-curve going right; seg1 comes back left, crossing seg0
|
|
// in the middle.
|
|
let mut dcel = Dcel::new();
|
|
|
|
// seg0 curves up-right, seg1 curves down-left, they cross.
|
|
let seg0 = CubicBez::new(
|
|
Point::new(0.0, 0.0),
|
|
Point::new(200.0, 0.0),
|
|
Point::new(200.0, 100.0),
|
|
Point::new(100.0, 50.0),
|
|
);
|
|
let seg1 = CubicBez::new(
|
|
Point::new(100.0, 50.0),
|
|
Point::new(0.0, 0.0), // pulls back left
|
|
Point::new(0.0, 100.0),
|
|
Point::new(200.0, 100.0),
|
|
);
|
|
|
|
let result = dcel.insert_stroke(&[seg0, seg1], None, None, 5.0);
|
|
|
|
eprintln!("new_vertices: {:?}", result.new_vertices);
|
|
eprintln!("new_edges: {:?}", result.new_edges);
|
|
eprintln!("new_faces: {:?}", result.new_faces);
|
|
let live_faces = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
eprintln!("total live faces: {}", live_faces);
|
|
|
|
// If the segments cross, we expect at least one new face beyond unbounded.
|
|
// If they don't cross, at least verify the stroke inserted without panic.
|
|
assert!(
|
|
result.new_edges.len() >= 2,
|
|
"expected at least 2 edges, got {}",
|
|
result.new_edges.len(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cross_then_circle() {
|
|
// Draw a cross (two strokes), then a circle crossing all 4 arms.
|
|
// This exercises insert_edge's angular half-edge selection at vertices
|
|
// where multiple edges share the same face.
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Horizontal stroke: (-100, 0) → (100, 0)
|
|
let h_seg = line_curve(Point::new(-100.0, 0.0), Point::new(100.0, 0.0));
|
|
dcel.insert_stroke(&[h_seg], None, None, 5.0);
|
|
|
|
// Vertical stroke: (0, -100) → (0, 100) — crosses horizontal at origin
|
|
let v_seg = line_curve(Point::new(0.0, -100.0), Point::new(0.0, 100.0));
|
|
dcel.insert_stroke(&[v_seg], None, None, 5.0);
|
|
|
|
let faces_before = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
eprintln!("faces after cross: {}", faces_before);
|
|
|
|
// Circle as 4 cubic segments, radius 50, centered at origin.
|
|
// Each arc covers 90 degrees.
|
|
// Using the standard cubic approximation: k = 4*(sqrt(2)-1)/3 ≈ 0.5523
|
|
let r = 50.0;
|
|
let k = r * 0.5522847498;
|
|
let circle_segs = [
|
|
// Top-right arc: (r,0) → (0,r)
|
|
CubicBez::new(
|
|
Point::new(r, 0.0), Point::new(r, k),
|
|
Point::new(k, r), Point::new(0.0, r),
|
|
),
|
|
// Top-left arc: (0,r) → (-r,0)
|
|
CubicBez::new(
|
|
Point::new(0.0, r), Point::new(-k, r),
|
|
Point::new(-r, k), Point::new(-r, 0.0),
|
|
),
|
|
// Bottom-left arc: (-r,0) → (0,-r)
|
|
CubicBez::new(
|
|
Point::new(-r, 0.0), Point::new(-r, -k),
|
|
Point::new(-k, -r), Point::new(0.0, -r),
|
|
),
|
|
// Bottom-right arc: (0,-r) → (r,0)
|
|
CubicBez::new(
|
|
Point::new(0.0, -r), Point::new(k, -r),
|
|
Point::new(r, -k), Point::new(r, 0.0),
|
|
),
|
|
];
|
|
let result = dcel.insert_stroke(&circle_segs, None, None, 5.0);
|
|
|
|
let live_faces = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
eprintln!("faces after circle: {} (new_faces: {:?})", live_faces, result.new_faces);
|
|
eprintln!("new_edges: {}", result.new_edges.len());
|
|
|
|
// The circle crosses all 4 arms, creating 4 intersection vertices.
|
|
// This should produce several faces (the 4 quadrant sectors inside the
|
|
// circle, plus the outside). validate() checks face consistency.
|
|
// The key assertion: it doesn't panic.
|
|
assert!(
|
|
live_faces >= 5,
|
|
"expected at least 5 faces (4 inner sectors + unbounded), got {}",
|
|
live_faces,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_drag_edge_into_self_intersection() {
|
|
// Insert a straight edge, then edit its curve to loop back on itself.
|
|
// recompute_edge_intersections should detect the self-crossing and split.
|
|
let mut dcel = Dcel::new();
|
|
|
|
let v1 = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
|
let v2 = dcel.alloc_vertex(Point::new(200.0, 0.0));
|
|
let straight = line_curve(Point::new(0.0, 0.0), Point::new(200.0, 0.0));
|
|
let (edge_id, _) = dcel.insert_edge(v1, v2, FaceId(0), straight);
|
|
dcel.validate();
|
|
|
|
let edges_before = dcel.edges.iter().filter(|e| !e.deleted).count();
|
|
|
|
// Now "drag" the edge into a self-intersecting loop (same curve as the
|
|
// single-segment self-intersection test).
|
|
dcel.edges[edge_id.idx()].curve = CubicBez::new(
|
|
Point::new(0.0, 0.0),
|
|
Point::new(300.0, 150.0),
|
|
Point::new(-100.0, 150.0),
|
|
Point::new(200.0, 0.0),
|
|
);
|
|
|
|
let result = dcel.recompute_edge_intersections(edge_id);
|
|
|
|
let edges_after = dcel.edges.iter().filter(|e| !e.deleted).count();
|
|
let faces_after = dcel.faces.iter().filter(|f| !f.deleted).count();
|
|
eprintln!("created: {:?}", result);
|
|
eprintln!("edges: {} → {}", edges_before, edges_after);
|
|
eprintln!("faces: {}", faces_after);
|
|
|
|
// The edge should have been split at the self-crossing.
|
|
assert!(
|
|
edges_after > edges_before,
|
|
"expected edge to be split by self-intersection ({} → {})",
|
|
edges_before,
|
|
edges_after,
|
|
);
|
|
assert!(
|
|
faces_after >= 2,
|
|
"expected at least 2 faces (loop + unbounded), got {}",
|
|
faces_after,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_recorded_seven_lines() {
|
|
// 7 line segments drawn across each other, creating triangles/quads/pentagon.
|
|
// Recorded from live editor with DAW_DCEL_RECORD=1.
|
|
let mut dcel = Dcel::new();
|
|
|
|
let strokes: Vec<Vec<CubicBez>> = vec![
|
|
vec![CubicBez::new(Point::new(172.3, 252.0), Point::new(342.2, 210.5), Point::new(512.0, 169.1), Point::new(681.8, 127.6))],
|
|
vec![CubicBez::new(Point::new(222.6, 325.7), Point::new(365.7, 248.3), Point::new(508.7, 171.0), Point::new(651.7, 93.7))],
|
|
vec![CubicBez::new(Point::new(210.4, 204.1), Point::new(359.4, 258.0), Point::new(508.4, 311.9), Point::new(657.5, 365.8))],
|
|
vec![CubicBez::new(Point::new(287.5, 333.0), Point::new(323.8, 238.4), Point::new(360.2, 143.9), Point::new(396.6, 49.3))],
|
|
vec![CubicBez::new(Point::new(425.9, 372.1), Point::new(418.7, 258.2), Point::new(411.6, 144.4), Point::new(404.5, 30.5))],
|
|
vec![CubicBez::new(Point::new(363.1, 360.1), Point::new(421.4, 263.3), Point::new(479.8, 166.6), Point::new(538.2, 69.9))],
|
|
vec![CubicBez::new(Point::new(292.8, 99.1), Point::new(398.5, 158.6), Point::new(504.3, 218.2), Point::new(610.0, 277.7))],
|
|
];
|
|
|
|
for segs in &strokes {
|
|
dcel.insert_stroke(segs, None, None, 5.0);
|
|
}
|
|
|
|
// Each paint point should hit a bounded face, and no two should share a face
|
|
let paint_points = vec![
|
|
Point::new(312.4, 224.1),
|
|
Point::new(325.5, 259.2),
|
|
Point::new(364.7, 223.4),
|
|
Point::new(402.9, 247.7),
|
|
Point::new(427.2, 226.3),
|
|
Point::new(431.6, 198.7),
|
|
Point::new(421.2, 181.6),
|
|
Point::new(364.7, 177.0),
|
|
];
|
|
let mut seen_faces = std::collections::HashSet::new();
|
|
for (i, &pt) in paint_points.iter().enumerate() {
|
|
let face = dcel.find_face_containing_point(pt);
|
|
assert!(
|
|
face.0 != 0,
|
|
"paint point {i} at ({:.1}, {:.1}) hit unbounded face",
|
|
pt.x, pt.y,
|
|
);
|
|
assert!(
|
|
seen_faces.insert(face),
|
|
"paint point {i} at ({:.1}, {:.1}) hit face {:?} which was already painted",
|
|
pt.x, pt.y, face,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_recorded_curves() {
|
|
// 7 curved strokes (one multi-segment). Recorded from live editor.
|
|
let mut dcel = Dcel::new();
|
|
|
|
let strokes: Vec<Vec<CubicBez>> = vec![
|
|
vec![CubicBez::new(Point::new(186.9, 301.1), Point::new(295.3, 221.6), Point::new(478.9, 181.7), Point::new(612.8, 148.2))],
|
|
vec![CubicBez::new(Point::new(159.8, 189.5), Point::new(315.6, 210.9), Point::new(500.4, 371.0), Point::new(600.7, 371.0))],
|
|
vec![CubicBez::new(Point::new(279.0, 330.6), Point::new(251.0, 262.7), Point::new(220.9, 175.9), Point::new(245.6, 102.1))],
|
|
vec![CubicBez::new(Point::new(183.3, 119.3), Point::new(250.6, 132.8), Point::new(542.6, 225.7), Point::new(575.6, 225.7))],
|
|
vec![CubicBez::new(Point::new(377.0, 353.6), Point::new(377.0, 280.8), Point::new(369.1, 166.5), Point::new(427.2, 108.5))],
|
|
vec![
|
|
CubicBez::new(Point::new(345.6, 333.3), Point::new(388.4, 299.7), Point::new(436.5, 274.6), Point::new(480.9, 243.5)),
|
|
CubicBez::new(Point::new(480.9, 243.5), Point::new(525.0, 212.5), Point::new(565.2, 174.9), Point::new(610.1, 145.0)),
|
|
],
|
|
vec![CubicBez::new(Point::new(493.5, 115.8), Point::new(475.6, 199.1), Point::new(461.0, 280.7), Point::new(461.0, 365.6))],
|
|
];
|
|
|
|
for segs in &strokes {
|
|
dcel.insert_stroke(segs, None, None, 5.0);
|
|
}
|
|
|
|
let paint_points = vec![
|
|
Point::new(255.6, 232.3),
|
|
Point::new(297.2, 200.0),
|
|
Point::new(342.6, 248.4),
|
|
Point::new(396.0, 192.5),
|
|
Point::new(403.5, 233.3),
|
|
Point::new(442.2, 288.3),
|
|
Point::new(490.6, 218.3),
|
|
Point::new(514.2, 194.9),
|
|
];
|
|
// Dump per-stroke topology
|
|
// Re-run from scratch with per-stroke tracking
|
|
let mut dcel2 = Dcel::new();
|
|
let strokes2 = strokes.clone();
|
|
for (s, segs) in strokes2.iter().enumerate() {
|
|
dcel2.insert_stroke(segs, None, None, 5.0);
|
|
let face_info: Vec<_> = dcel2.faces.iter().enumerate()
|
|
.filter(|(i, f)| !f.deleted && *i > 0 && !f.outer_half_edge.is_none())
|
|
.map(|(i, _)| {
|
|
let cycle = dcel2.face_boundary(FaceId(i as u32));
|
|
(i, cycle.len())
|
|
}).collect();
|
|
eprintln!("After stroke {s}: faces={:?}", face_info);
|
|
}
|
|
|
|
// Dump all faces with cycle lengths
|
|
for (i, face) in dcel.faces.iter().enumerate() {
|
|
if face.deleted || i == 0 { continue; }
|
|
if face.outer_half_edge.is_none() { continue; }
|
|
let cycle = dcel.face_boundary(FaceId(i as u32));
|
|
let path = dcel.face_to_bezpath(FaceId(i as u32));
|
|
let area = kurbo::Shape::area(&path).abs();
|
|
eprintln!(" Face {i}: cycle_len={}, area={:.1}", cycle.len(), area);
|
|
}
|
|
|
|
let mut seen_faces = std::collections::HashSet::new();
|
|
for (i, &pt) in paint_points.iter().enumerate() {
|
|
let face = dcel.find_face_containing_point(pt);
|
|
let cycle_len = if face.0 != 0 {
|
|
dcel.face_boundary(face).len()
|
|
} else { 0 };
|
|
eprintln!("paint point {i} at ({:.1}, {:.1}) → face {:?} (cycle_len={})", pt.x, pt.y, face, cycle_len);
|
|
assert!(
|
|
face.0 != 0,
|
|
"paint point {i} at ({:.1}, {:.1}) hit unbounded face",
|
|
pt.x, pt.y,
|
|
);
|
|
assert!(
|
|
seen_faces.insert(face),
|
|
"paint point {i} at ({:.1}, {:.1}) hit face {:?} which was already painted",
|
|
pt.x, pt.y, face,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_recorded_complex_curves() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Stroke 0
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(285.4, 88.3), Point::new(211.5, 148.8), Point::new(140.3, 214.8), Point::new(98.2, 301.9)),
|
|
CubicBez::new(Point::new(98.2, 301.9), Point::new(83.7, 331.9), Point::new(71.1, 364.5), Point::new(52.5, 392.4)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 1
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(96.5, 281.3), Point::new(244.8, 254.4), Point::new(304.4, 327.7), Point::new(427.7, 327.7)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 2
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(88.8, 86.7), Point::new(141.9, 105.4), Point::new(194.0, 126.2), Point::new(240.4, 158.6)),
|
|
CubicBez::new(Point::new(240.4, 158.6), Point::new(273.3, 181.6), Point::new(297.7, 213.4), Point::new(327.6, 239.5)),
|
|
CubicBez::new(Point::new(327.6, 239.5), Point::new(378.8, 284.1), Point::new(451.3, 317.7), Point::new(467.3, 389.8)),
|
|
CubicBez::new(Point::new(467.3, 389.8), Point::new(470.1, 402.3), Point::new(480.1, 418.3), Point::new(461.2, 410.8)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 3
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(320.6, 375.9), Point::new(359.8, 251.8), Point::new(402.3, 201.6), Point::new(525.7, 160.4)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 4
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(72.2, 181.1), Point::new(97.2, 211.1), Point::new(129.2, 234.8), Point::new(154.8, 264.6)),
|
|
CubicBez::new(Point::new(154.8, 264.6), Point::new(182.3, 296.5), Point::new(199.7, 334.9), Point::new(232.1, 363.0)),
|
|
CubicBez::new(Point::new(232.1, 363.0), Point::new(251.8, 380.1), Point::new(276.7, 390.0), Point::new(295.4, 408.7)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 5
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(102.9, 316.2), Point::new(167.0, 209.3), Point::new(263.1, 110.6), Point::new(399.0, 110.6)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 6
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(159.4, 87.6), Point::new(216.5, 159.0), Point::new(260.1, 346.3), Point::new(229.7, 437.4)),
|
|
], None, None, 5.0);
|
|
|
|
// Points 6, 7, 8 should each hit unique bounded faces
|
|
let paint_points = vec![
|
|
Point::new(217.4, 160.1),
|
|
Point::new(184.2, 242.9),
|
|
Point::new(202.0, 141.4),
|
|
];
|
|
let mut seen_faces = std::collections::HashSet::new();
|
|
for (i, &pt) in paint_points.iter().enumerate() {
|
|
let face = dcel.find_face_containing_point(pt);
|
|
assert!(
|
|
face.0 != 0,
|
|
"paint point {i} at ({:.1}, {:.1}) hit unbounded face",
|
|
pt.x, pt.y,
|
|
);
|
|
assert!(
|
|
seen_faces.insert(face),
|
|
"paint point {i} at ({:.1}, {:.1}) hit face {:?} which was already painted",
|
|
pt.x, pt.y, face,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_d_shape_fill() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Stroke 0: vertical line
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(354.2, 97.9), Point::new(354.2, 208.0), Point::new(357.7, 318.7), Point::new(357.7, 429.0)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 1: inner curve of D
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(332.9, 218.6), Point::new(359.1, 224.5), Point::new(386.8, 225.0), Point::new(412.0, 234.5)),
|
|
CubicBez::new(Point::new(412.0, 234.5), Point::new(457.5, 251.5), Point::new(416.1, 313.5), Point::new(287.3, 313.5)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 2: outer curve of D
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(319.5, 154.5), Point::new(548.7, 154.5), Point::new(553.4, 359.5), Point::new(337.9, 392.6)),
|
|
CubicBez::new(Point::new(337.9, 392.6), Point::new(310.3, 396.9), Point::new(279.8, 405.8), Point::new(251.8, 398.8)),
|
|
], None, None, 5.0);
|
|
|
|
// The D-shape region should be fillable
|
|
let face = dcel.find_face_containing_point(Point::new(439.8, 319.6));
|
|
assert!(face.0 != 0, "D-shape region hit unbounded face");
|
|
}
|
|
|
|
#[test]
|
|
fn test_recorded_seven_strokes() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(194.8, 81.4), Point::new(314.0, 126.0), Point::new(413.6, 198.4), Point::new(518.5, 268.3)),
|
|
CubicBez::new(Point::new(518.5, 268.3), Point::new(558.0, 294.7), Point::new(598.6, 322.6), Point::new(638.9, 347.4)),
|
|
CubicBez::new(Point::new(638.9, 347.4), Point::new(646.8, 352.3), Point::new(672.4, 358.1), Point::new(663.5, 360.6)),
|
|
CubicBez::new(Point::new(663.5, 360.6), Point::new(654.9, 363.0), Point::new(644.3, 358.5), Point::new(636.2, 356.2)),
|
|
], None, None, 5.0);
|
|
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(223.9, 308.2), Point::new(392.2, 242.0), Point::new(603.6, 211.2), Point::new(786.1, 211.2)),
|
|
], None, None, 5.0);
|
|
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(157.2, 201.6), Point::new(287.7, 136.3), Point::new(442.7, 100.0), Point::new(589.3, 100.0)),
|
|
], None, None, 5.0);
|
|
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(247.4, 56.4), Point::new(284.2, 122.7), Point::new(271.2, 201.4), Point::new(289.0, 272.2)),
|
|
CubicBez::new(Point::new(289.0, 272.2), Point::new(298.4, 310.2), Point::new(314.3, 344.7), Point::new(327.6, 380.0)),
|
|
], None, None, 5.0);
|
|
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(249.3, 383.6), Point::new(287.6, 353.0), Point::new(604.8, 19.5), Point::new(612.9, 17.5)),
|
|
], None, None, 5.0);
|
|
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(436.9, 73.9), Point::new(520.7, 157.8), Point::new(574.8, 262.5), Point::new(574.8, 383.2)),
|
|
], None, None, 5.0);
|
|
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(361.1, 356.7), Point::new(311.8, 291.0), Point::new(299.5, 204.6), Point::new(174.0, 183.6)),
|
|
], None, None, 5.0);
|
|
|
|
let paint_points = vec![
|
|
Point::new(303.9, 296.1),
|
|
Point::new(290.4, 260.4),
|
|
Point::new(245.1, 186.4),
|
|
Point::new(284.2, 133.3),
|
|
Point::new(334.1, 201.4),
|
|
Point::new(329.3, 283.7),
|
|
Point::new(425.6, 229.4),
|
|
Point::new(405.2, 145.5),
|
|
Point::new(492.9, 115.7),
|
|
Point::new(480.0, 208.1),
|
|
Point::new(521.2, 249.9),
|
|
];
|
|
assert_paint_sequence(&mut dcel, &paint_points, 800, 450);
|
|
}
|
|
|
|
#[test]
|
|
fn test_concentric_ellipses() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Stroke 0 — inner ellipse
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(547.7, 237.8), Point::new(547.7, 218.3), Point::new(518.1, 202.6), Point::new(481.4, 202.6)),
|
|
CubicBez::new(Point::new(481.4, 202.6), Point::new(444.8, 202.6), Point::new(415.1, 218.3), Point::new(415.1, 237.8)),
|
|
CubicBez::new(Point::new(415.1, 237.8), Point::new(415.1, 257.2), Point::new(444.8, 272.9), Point::new(481.4, 272.9)),
|
|
CubicBez::new(Point::new(481.4, 272.9), Point::new(518.1, 272.9), Point::new(547.7, 257.2), Point::new(547.7, 237.8)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 1 — outer ellipse
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(693.6, 255.9), Point::new(693.6, 197.6), Point::new(609.8, 150.3), Point::new(506.5, 150.3)),
|
|
CubicBez::new(Point::new(506.5, 150.3), Point::new(403.2, 150.3), Point::new(319.5, 197.6), Point::new(319.5, 255.9)),
|
|
CubicBez::new(Point::new(319.5, 255.9), Point::new(319.5, 314.2), Point::new(403.2, 361.5), Point::new(506.5, 361.5)),
|
|
CubicBez::new(Point::new(506.5, 361.5), Point::new(609.8, 361.5), Point::new(693.6, 314.2), Point::new(693.6, 255.9)),
|
|
], None, None, 5.0);
|
|
|
|
// Test both orderings — outer first should also work
|
|
let paint_points = vec![
|
|
Point::new(400.5, 319.5),
|
|
Point::new(497.0, 251.4),
|
|
];
|
|
assert_paint_sequence(&mut dcel, &paint_points, 800, 450);
|
|
}
|
|
|
|
#[test]
|
|
fn test_recorded_eight_strokes() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Stroke 0
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(205.0, 366.2), Point::new(244.7, 255.0), Point::new(301.5, 184.3), Point::new(398.7, 119.5)),
|
|
CubicBez::new(Point::new(398.7, 119.5), Point::new(419.4, 105.7), Point::new(438.3, 87.0), Point::new(464.6, 87.0)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 1
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(131.7, 126.8), Point::new(278.6, 184.4), Point::new(420.9, 260.3), Point::new(570.1, 310.0)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 2
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(252.7, 369.6), Point::new(245.6, 297.8), Point::new(246.6, 225.3), Point::new(240.6, 153.5)),
|
|
CubicBez::new(Point::new(240.6, 153.5), Point::new(238.9, 132.9), Point::new(228.3, 112.7), Point::new(228.3, 92.0)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 3
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(362.6, 105.6), Point::new(317.6, 210.5), Point::new(160.1, 315.5), Point::new(149.0, 332.1)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 4
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(134.6, 218.2), Point::new(228.4, 208.3), Point::new(368.1, 233.7), Point::new(458.8, 263.9)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 5
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(329.0, 300.6), Point::new(339.5, 221.5), Point::new(342.3, 147.5), Point::new(316.7, 70.4)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 6
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(186.0, 99.2), Point::new(263.5, 118.6), Point::new(342.2, 129.8), Point::new(417.9, 156.3)),
|
|
CubicBez::new(Point::new(417.9, 156.3), Point::new(456.4, 169.8), Point::new(494.6, 191.3), Point::new(533.9, 201.1)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 7
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(287.5, 73.5), Point::new(266.9, 135.2), Point::new(224.9, 188.7), Point::new(202.3, 251.0)),
|
|
CubicBez::new(Point::new(202.3, 251.0), Point::new(187.7, 291.0), Point::new(194.5, 335.7), Point::new(181.2, 375.8)),
|
|
], None, None, 5.0);
|
|
|
|
// Dump face topology after all strokes
|
|
for (i, face) in dcel.faces.iter().enumerate() {
|
|
if face.deleted || i == 0 { continue; }
|
|
if face.outer_half_edge.is_none() { continue; }
|
|
let cycle = dcel.face_boundary(FaceId(i as u32));
|
|
let path = dcel.face_to_bezpath(FaceId(i as u32));
|
|
let area = kurbo::Shape::area(&path).abs();
|
|
eprintln!(" Face {i}: cycle_len={}, area={:.1}", cycle.len(), area);
|
|
if cycle.len() > 20 {
|
|
// Dump the full cycle for bloated faces
|
|
let start = face.outer_half_edge;
|
|
let mut cur = start;
|
|
let mut step = 0;
|
|
loop {
|
|
let he = &dcel.half_edges[cur.idx()];
|
|
let origin = he.origin;
|
|
let pos = dcel.vertices[origin.idx()].position;
|
|
let edge = he.edge;
|
|
let twin = dcel.edges[edge.idx()].half_edges;
|
|
let is_fwd = twin[0] == cur;
|
|
eprintln!(" step {step}: he={:?} origin={:?} ({:.1},{:.1}) edge={:?} dir={}",
|
|
cur, origin, pos.x, pos.y, edge, if is_fwd {"fwd"} else {"bwd"});
|
|
cur = he.next;
|
|
step += 1;
|
|
if cur == start || step > 60 { break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check what face each point lands on
|
|
let paint_points = vec![
|
|
Point::new(219.8, 233.7),
|
|
Point::new(227.2, 205.8),
|
|
Point::new(253.2, 203.3),
|
|
Point::new(281.2, 149.0),
|
|
];
|
|
for (i, &pt) in paint_points.iter().enumerate() {
|
|
let face = dcel.find_face_containing_point(pt);
|
|
if face.0 != 0 {
|
|
let cycle = dcel.face_boundary(face);
|
|
let stripped = dcel.strip_cycle(&cycle);
|
|
let path_raw = dcel.face_to_bezpath(face);
|
|
let path_stripped = dcel.face_to_bezpath_stripped(face);
|
|
let area_raw = kurbo::Shape::area(&path_raw).abs();
|
|
let area_stripped = kurbo::Shape::area(&path_stripped).abs();
|
|
eprintln!("paint point {i} at ({:.1}, {:.1}) → face {:?} raw_len={} stripped_len={} raw_area={:.1} stripped_area={:.1}",
|
|
pt.x, pt.y, face, cycle.len(), stripped.len(), area_raw, area_stripped);
|
|
if i == 2 {
|
|
eprintln!(" Raw cycle vertices for face {:?}:", face);
|
|
for (j, &he_id) in cycle.iter().enumerate() {
|
|
let src = dcel.half_edge_source(he_id);
|
|
let pos = dcel.vertex(src).position;
|
|
eprintln!(" step {j}: HE{} src=V{} ({:.1},{:.1})", he_id.0, src.0, pos.x, pos.y);
|
|
}
|
|
eprintln!(" Stripped cycle vertices for face {:?}:", face);
|
|
for (j, &he_id) in stripped.iter().enumerate() {
|
|
let src = dcel.half_edge_source(he_id);
|
|
let pos = dcel.vertex(src).position;
|
|
eprintln!(" step {j}: HE{} src=V{} ({:.1},{:.1})", he_id.0, src.0, pos.x, pos.y);
|
|
}
|
|
}
|
|
} else {
|
|
eprintln!("paint point {i} at ({:.1}, {:.1}) → UNBOUNDED", pt.x, pt.y);
|
|
}
|
|
}
|
|
|
|
assert_paint_sequence(&mut dcel, &paint_points, 600, 400);
|
|
}
|
|
|
|
#[test]
|
|
fn test_recorded_six_strokes_four_fills() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Stroke 0
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(279.5, 405.9), Point::new(342.3, 330.5), Point::new(404.0, 254.0), Point::new(478.1, 188.9)),
|
|
CubicBez::new(Point::new(478.1, 188.9), Point::new(505.1, 165.2), Point::new(539.1, 148.1), Point::new(564.2, 123.0)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 1
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(281.5, 209.9), Point::new(414.0, 241.1), Point::new(556.8, 218.5), Point::new(684.7, 269.7)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 2
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(465.3, 334.9), Point::new(410.9, 307.7), Point::new(370.5, 264.5), Point::new(343.4, 210.4)),
|
|
CubicBez::new(Point::new(343.4, 210.4), Point::new(337.5, 198.6), Point::new(321.9, 120.9), Point::new(303.9, 120.9)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 3
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(244.0, 290.7), Point::new(281.2, 279.8), Point::new(474.1, 242.2), Point::new(511.9, 237.8)),
|
|
CubicBez::new(Point::new(511.9, 237.8), Point::new(540.4, 234.5), Point::new(569.7, 236.9), Point::new(598.0, 231.7)),
|
|
CubicBez::new(Point::new(598.0, 231.7), Point::new(620.5, 227.5), Point::new(699.3, 190.4), Point::new(703.4, 190.4)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 4
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(303.2, 146.2), Point::new(442.0, 146.2), Point::new(598.7, 124.5), Point::new(674.9, 269.2)),
|
|
CubicBez::new(Point::new(674.9, 269.2), Point::new(684.7, 287.9), Point::new(699.5, 302.6), Point::new(699.5, 324.2)),
|
|
], None, None, 5.0);
|
|
|
|
// Stroke 5
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(409.7, 328.3), Point::new(389.8, 248.7), Point::new(409.7, 161.3), Point::new(409.7, 80.6)),
|
|
], None, None, 5.0);
|
|
|
|
let paint_points = vec![
|
|
Point::new(403.0, 257.7),
|
|
Point::new(392.0, 263.6),
|
|
Point::new(381.1, 235.2),
|
|
Point::new(357.0, 167.1),
|
|
];
|
|
|
|
// Dump all vertices
|
|
eprintln!("=== All vertices ===");
|
|
for (i, v) in dcel.vertices.iter().enumerate() {
|
|
if v.deleted { continue; }
|
|
eprintln!(" V{i} ({:.1},{:.1}) outgoing=HE{}", v.position.x, v.position.y, v.outgoing.0);
|
|
}
|
|
|
|
// Debug: show what faces each point would hit and their areas
|
|
for (i, &pt) in paint_points.iter().enumerate() {
|
|
use kurbo::Shape as _;
|
|
let face = dcel.find_face_containing_point(pt);
|
|
if face.0 != 0 {
|
|
let cycle = dcel.face_boundary(face);
|
|
let stripped = dcel.strip_cycle(&cycle);
|
|
let path = dcel.face_to_bezpath_stripped(face);
|
|
let area = path.area().abs();
|
|
eprintln!(" point {i} ({:.1},{:.1}) → F{} cycle_len={} stripped_len={} area={:.1}",
|
|
pt.x, pt.y, face.0, cycle.len(), stripped.len(), area);
|
|
// Show stripped cycle vertices
|
|
for (j, &he_id) in stripped.iter().enumerate() {
|
|
let src = dcel.half_edge_source(he_id);
|
|
let pos = dcel.vertex(src).position;
|
|
eprintln!(" [{j}] HE{} V{} ({:.1},{:.1})", he_id.0, src.0, pos.x, pos.y);
|
|
}
|
|
} else {
|
|
eprintln!(" point {i} ({:.1},{:.1}) → UNBOUNDED", pt.x, pt.y);
|
|
}
|
|
}
|
|
|
|
// Dump SVG for debugging
|
|
{
|
|
let mut svg = String::new();
|
|
svg.push_str("<svg xmlns='http://www.w3.org/2000/svg' width='750' height='450'>\n");
|
|
svg.push_str("<rect width='750' height='450' fill='white'/>\n");
|
|
let colors = ["#e6194b","#3cb44b","#4363d8","#f58231","#911eb4",
|
|
"#42d4f4","#f032e6","#bfef45","#fabed4","#469990",
|
|
"#dcbeff","#9A6324","#800000","#aaffc3","#808000",
|
|
"#ffd8b1","#000075","#808080","#000000","#ffe119"];
|
|
for (i, e) in dcel.edges.iter().enumerate() {
|
|
if e.deleted { continue; }
|
|
let c = &e.curve;
|
|
let color = colors[i % colors.len()];
|
|
let v0 = dcel.half_edges[e.half_edges[0].idx()].origin;
|
|
let v1 = dcel.half_edges[e.half_edges[1].idx()].origin;
|
|
svg.push_str(&format!(
|
|
"<path d='M{:.1},{:.1} C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}' fill='none' stroke='{}' stroke-width='1.5'/>\n",
|
|
c.p0.x, c.p0.y, c.p1.x, c.p1.y, c.p2.x, c.p2.y, c.p3.x, c.p3.y, color
|
|
));
|
|
let mid = c.eval(0.5);
|
|
svg.push_str(&format!(
|
|
"<text x='{:.1}' y='{:.1}' font-size='7' fill='{}'>E{}(V{}→V{})</text>\n",
|
|
mid.x, mid.y - 2.0, color, i, v0.0, v1.0
|
|
));
|
|
}
|
|
for (i, v) in dcel.vertices.iter().enumerate() {
|
|
if v.deleted { continue; }
|
|
svg.push_str(&format!(
|
|
"<circle cx='{:.1}' cy='{:.1}' r='2' fill='red'/>\n\
|
|
<text x='{:.1}' y='{:.1}' font-size='7' fill='red'>V{}</text>\n",
|
|
v.position.x, v.position.y, v.position.x + 3.0, v.position.y - 3.0, i
|
|
));
|
|
}
|
|
for (i, &pt) in paint_points.iter().enumerate() {
|
|
svg.push_str(&format!(
|
|
"<circle cx='{:.1}' cy='{:.1}' r='4' fill='none' stroke='magenta' stroke-width='1.5'/>\n\
|
|
<text x='{:.1}' y='{:.1}' font-size='8' fill='magenta'>P{}</text>\n",
|
|
pt.x, pt.y, pt.x + 5.0, pt.y - 5.0, i
|
|
));
|
|
}
|
|
svg.push_str("</svg>\n");
|
|
std::fs::write("/tmp/dcel_six_strokes.svg", &svg).unwrap();
|
|
eprintln!("SVG written to /tmp/dcel_six_strokes.svg");
|
|
}
|
|
|
|
assert_paint_sequence(&mut dcel, &paint_points, 750, 450);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dump_svg() {
|
|
let mut dcel = Dcel::new();
|
|
|
|
// Same 8 strokes as test_recorded_eight_strokes
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(205.0, 366.2), Point::new(244.7, 255.0), Point::new(301.5, 184.3), Point::new(398.7, 119.5)),
|
|
CubicBez::new(Point::new(398.7, 119.5), Point::new(419.4, 105.7), Point::new(438.3, 87.0), Point::new(464.6, 87.0)),
|
|
], None, None, 5.0);
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(131.7, 126.8), Point::new(278.6, 184.4), Point::new(420.9, 260.3), Point::new(570.1, 310.0)),
|
|
], None, None, 5.0);
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(252.7, 369.6), Point::new(245.6, 297.8), Point::new(246.6, 225.3), Point::new(240.6, 153.5)),
|
|
CubicBez::new(Point::new(240.6, 153.5), Point::new(238.9, 132.9), Point::new(228.3, 112.7), Point::new(228.3, 92.0)),
|
|
], None, None, 5.0);
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(362.6, 105.6), Point::new(317.6, 210.5), Point::new(160.1, 315.5), Point::new(149.0, 332.1)),
|
|
], None, None, 5.0);
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(134.6, 218.2), Point::new(228.4, 208.3), Point::new(368.1, 233.7), Point::new(458.8, 263.9)),
|
|
], None, None, 5.0);
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(329.0, 300.6), Point::new(339.5, 221.5), Point::new(342.3, 147.5), Point::new(316.7, 70.4)),
|
|
], None, None, 5.0);
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(186.0, 99.2), Point::new(263.5, 118.6), Point::new(342.2, 129.8), Point::new(417.9, 156.3)),
|
|
CubicBez::new(Point::new(417.9, 156.3), Point::new(456.4, 169.8), Point::new(494.6, 191.3), Point::new(533.9, 201.1)),
|
|
], None, None, 5.0);
|
|
dcel.insert_stroke(&[
|
|
CubicBez::new(Point::new(287.5, 73.5), Point::new(266.9, 135.2), Point::new(224.9, 188.7), Point::new(202.3, 251.0)),
|
|
CubicBez::new(Point::new(202.3, 251.0), Point::new(187.7, 291.0), Point::new(194.5, 335.7), Point::new(181.2, 375.8)),
|
|
], None, None, 5.0);
|
|
|
|
// Generate distinct colors via HSL hue rotation
|
|
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
|
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
|
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
|
let m = l - c / 2.0;
|
|
let (r1, g1, b1) = if h < 60.0 { (c, x, 0.0) }
|
|
else if h < 120.0 { (x, c, 0.0) }
|
|
else if h < 180.0 { (0.0, c, x) }
|
|
else if h < 240.0 { (0.0, x, c) }
|
|
else if h < 300.0 { (x, 0.0, c) }
|
|
else { (c, 0.0, x) };
|
|
(((r1 + m) * 255.0) as u8, ((g1 + m) * 255.0) as u8, ((b1 + m) * 255.0) as u8)
|
|
}
|
|
|
|
let mut svg = String::new();
|
|
svg.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"100 40 500 380\" width=\"1200\" height=\"912\">\n");
|
|
svg.push_str("<rect x=\"100\" y=\"40\" width=\"500\" height=\"380\" fill=\"white\"/>\n");
|
|
svg.push_str("<defs><marker id=\"ah\" markerWidth=\"1.6\" markerHeight=\"1.2\" refX=\"1.6\" refY=\"0.6\" orient=\"auto\"><path d=\"M0,0 L1.6,0.6 L0,1.2\" fill=\"context-stroke\"/></marker></defs>\n");
|
|
|
|
// Draw each half-edge as a colored arrow
|
|
let n_he = dcel.half_edges.len();
|
|
for (i, he) in dcel.half_edges.iter().enumerate() {
|
|
if he.deleted { continue; }
|
|
let he_id = HalfEdgeId(i as u32);
|
|
let edge = &dcel.edges[he.edge.idx()];
|
|
let is_fwd = edge.half_edges[0] == he_id;
|
|
|
|
let curve = if is_fwd {
|
|
edge.curve
|
|
} else {
|
|
CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0)
|
|
};
|
|
|
|
// Color based on half-edge index
|
|
let hue = (i as f64 / n_he as f64) * 360.0;
|
|
let (r, g, b) = hsl_to_rgb(hue, 0.9, 0.4);
|
|
|
|
// Offset slightly so fwd/bwd don't overlap perfectly
|
|
let offset = if is_fwd { -1.5 } else { 1.5 };
|
|
// Simple normal offset: perpendicular to start→end direction
|
|
let dx = curve.p3.x - curve.p0.x;
|
|
let dy = curve.p3.y - curve.p0.y;
|
|
let len = (dx * dx + dy * dy).sqrt().max(0.01);
|
|
let nx = -dy / len * offset;
|
|
let ny = dx / len * offset;
|
|
|
|
svg.push_str(&format!(
|
|
"<path d=\"M{:.1},{:.1} C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}\" \
|
|
fill=\"none\" stroke=\"rgb({r},{g},{b})\" stroke-width=\"1.5\" \
|
|
marker-end=\"url(#ah)\" opacity=\"0.8\">\
|
|
<title>HE{i} E{} F{} {}</title></path>\n",
|
|
curve.p0.x + nx, curve.p0.y + ny,
|
|
curve.p1.x + nx, curve.p1.y + ny,
|
|
curve.p2.x + nx, curve.p2.y + ny,
|
|
curve.p3.x + nx, curve.p3.y + ny,
|
|
he.edge.0, he.face.0, if is_fwd { "fwd" } else { "bwd" },
|
|
));
|
|
|
|
// Label near destination (t=0.85) so fwd/bwd labels don't overlap
|
|
let label_pt = curve.eval(0.85);
|
|
svg.push_str(&format!(
|
|
"<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"1.4\" fill=\"rgb({r},{g},{b})\" \
|
|
text-anchor=\"middle\" dominant-baseline=\"middle\" opacity=\"0.9\">HE{i}</text>\n",
|
|
label_pt.x + nx * 3.0, label_pt.y + ny * 3.0,
|
|
));
|
|
}
|
|
|
|
// Draw vertices as labeled circles
|
|
for (i, v) in dcel.vertices.iter().enumerate() {
|
|
if v.deleted { continue; }
|
|
svg.push_str(&format!(
|
|
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"0.6\" fill=\"black\"/>\n\
|
|
<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"1.8\" font-weight=\"bold\" \
|
|
fill=\"black\" text-anchor=\"start\" dominant-baseline=\"hanging\">V{i}</text>\n",
|
|
v.position.x, v.position.y,
|
|
v.position.x + 1.0, v.position.y + 0.2,
|
|
));
|
|
}
|
|
|
|
// Mark paint points
|
|
let paint_points = [
|
|
(219.8, 233.7, "P0"),
|
|
(227.2, 205.8, "P1"),
|
|
(253.2, 203.3, "P2"),
|
|
(281.2, 149.0, "P3"),
|
|
];
|
|
for (x, y, label) in &paint_points {
|
|
let is_p2 = *label == "P2";
|
|
let color = if is_p2 { "magenta" } else { "red" };
|
|
let r = if is_p2 { "1.4" } else { "1.0" };
|
|
let sw = if is_p2 { "0.6" } else { "0.4" };
|
|
let extra = if is_p2 { " (BLOATED)" } else { "" };
|
|
svg.push_str(&format!(
|
|
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{sw}\"/>\n\
|
|
<text x=\"{}\" y=\"{}\" font-size=\"2\" font-weight=\"bold\" fill=\"{color}\">{label}{extra}</text>\n",
|
|
x + 1.5, y - 0.4,
|
|
));
|
|
}
|
|
|
|
// Highlight Face 15 stripped cycle
|
|
let face15 = FaceId(15);
|
|
if !dcel.faces[15].deleted && !dcel.faces[15].outer_half_edge.is_none() {
|
|
let cycle = dcel.face_boundary(face15);
|
|
let stripped = dcel.strip_cycle(&cycle);
|
|
let mut d = String::new();
|
|
for (j, &he_id) in stripped.iter().enumerate() {
|
|
let edge = &dcel.edges[dcel.half_edge(he_id).edge.idx()];
|
|
let is_fwd = edge.half_edges[0] == he_id;
|
|
let curve = if is_fwd {
|
|
edge.curve
|
|
} else {
|
|
CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0)
|
|
};
|
|
if j == 0 {
|
|
d.push_str(&format!("M{:.1},{:.1} ", curve.p0.x, curve.p0.y));
|
|
}
|
|
d.push_str(&format!("C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} ",
|
|
curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y));
|
|
}
|
|
d.push_str("Z");
|
|
svg.push_str(&format!(
|
|
"<path d=\"{d}\" fill=\"rgba(255,0,0,0.12)\" stroke=\"red\" stroke-width=\"2\" stroke-dasharray=\"6,3\">\
|
|
<title>Face 15 stripped cycle ({} edges)</title></path>\n",
|
|
stripped.len(),
|
|
));
|
|
}
|
|
|
|
svg.push_str("</svg>\n");
|
|
|
|
std::fs::write("/tmp/dcel_debug.svg", &svg).expect("write SVG");
|
|
eprintln!("Wrote /tmp/dcel_debug.svg");
|
|
|
|
// --- Zoomed SVG around P2, V7/V37, V3/V11 ---
|
|
let mut svg2 = String::new();
|
|
// V38=(241.8,169.1) V7=(246.8,274.7) — center ~(244, 222), span ~130
|
|
svg2.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"215 155 65 135\" width=\"975\" height=\"2025\">\n");
|
|
svg2.push_str("<rect x=\"215\" y=\"155\" width=\"65\" height=\"135\" fill=\"white\"/>\n");
|
|
svg2.push_str("<defs><marker id=\"ah\" markerWidth=\"1.6\" markerHeight=\"1.2\" refX=\"1.6\" refY=\"0.6\" orient=\"auto\"><path d=\"M0,0 L1.6,0.6 L0,1.2\" fill=\"context-stroke\"/></marker></defs>\n");
|
|
|
|
// Draw all half-edges (clipped by viewBox naturally)
|
|
for (i, he) in dcel.half_edges.iter().enumerate() {
|
|
if he.deleted { continue; }
|
|
let he_id = HalfEdgeId(i as u32);
|
|
let edge = &dcel.edges[he.edge.idx()];
|
|
let is_fwd = edge.half_edges[0] == he_id;
|
|
let curve = if is_fwd {
|
|
edge.curve
|
|
} else {
|
|
CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0)
|
|
};
|
|
let hue = (i as f64 / n_he as f64) * 360.0;
|
|
let (r, g, b) = hsl_to_rgb(hue, 0.9, 0.4);
|
|
let offset = if is_fwd { -0.8 } else { 0.8 };
|
|
let dx = curve.p3.x - curve.p0.x;
|
|
let dy = curve.p3.y - curve.p0.y;
|
|
let len = (dx * dx + dy * dy).sqrt().max(0.01);
|
|
let nx = -dy / len * offset;
|
|
let ny = dx / len * offset;
|
|
|
|
svg2.push_str(&format!(
|
|
"<path d=\"M{:.1},{:.1} C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}\" \
|
|
fill=\"none\" stroke=\"rgb({r},{g},{b})\" stroke-width=\"0.4\" \
|
|
marker-end=\"url(#ah)\" opacity=\"0.8\">\
|
|
<title>HE{i} E{} F{} {}</title></path>\n",
|
|
curve.p0.x + nx, curve.p0.y + ny,
|
|
curve.p1.x + nx, curve.p1.y + ny,
|
|
curve.p2.x + nx, curve.p2.y + ny,
|
|
curve.p3.x + nx, curve.p3.y + ny,
|
|
he.edge.0, he.face.0, if is_fwd { "fwd" } else { "bwd" },
|
|
));
|
|
|
|
let label_pt = curve.eval(0.85);
|
|
svg2.push_str(&format!(
|
|
"<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"1.2\" fill=\"rgb({r},{g},{b})\" \
|
|
text-anchor=\"middle\" dominant-baseline=\"middle\" opacity=\"0.9\">HE{i}</text>\n",
|
|
label_pt.x + nx * 2.0, label_pt.y + ny * 2.0,
|
|
));
|
|
}
|
|
|
|
// Vertices
|
|
for (i, v) in dcel.vertices.iter().enumerate() {
|
|
if v.deleted { continue; }
|
|
// Highlight V38,V7 specially
|
|
let special = matches!(i, 38 | 7);
|
|
let (fill, rad, fs) = if special {
|
|
("blue", "0.8", "1.6")
|
|
} else {
|
|
("black", "0.4", "1.2")
|
|
};
|
|
svg2.push_str(&format!(
|
|
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{rad}\" fill=\"{fill}\"/>\n\
|
|
<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"{fs}\" font-weight=\"bold\" \
|
|
fill=\"{fill}\" text-anchor=\"start\" dominant-baseline=\"hanging\">V{i}</text>\n",
|
|
v.position.x, v.position.y,
|
|
v.position.x + 1.0, v.position.y + 0.2,
|
|
));
|
|
}
|
|
|
|
// Paint points
|
|
for (x, y, label) in &paint_points {
|
|
let is_p2 = *label == "P2";
|
|
let color = if is_p2 { "magenta" } else { "red" };
|
|
let r = if is_p2 { "1.4" } else { "1.0" };
|
|
let sw = if is_p2 { "0.3" } else { "0.2" };
|
|
svg2.push_str(&format!(
|
|
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{r}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"{sw}\"/>\n\
|
|
<text x=\"{}\" y=\"{}\" font-size=\"1.6\" font-weight=\"bold\" fill=\"{color}\">{label}</text>\n",
|
|
x + 1.5, y - 0.4,
|
|
));
|
|
}
|
|
|
|
// Face 15 stripped outline
|
|
if !dcel.faces[15].deleted && !dcel.faces[15].outer_half_edge.is_none() {
|
|
let cycle = dcel.face_boundary(face15);
|
|
let stripped = dcel.strip_cycle(&cycle);
|
|
let mut d = String::new();
|
|
for (j, &he_id) in stripped.iter().enumerate() {
|
|
let edge = &dcel.edges[dcel.half_edge(he_id).edge.idx()];
|
|
let is_fwd = edge.half_edges[0] == he_id;
|
|
let curve = if is_fwd {
|
|
edge.curve
|
|
} else {
|
|
CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0)
|
|
};
|
|
if j == 0 {
|
|
d.push_str(&format!("M{:.1},{:.1} ", curve.p0.x, curve.p0.y));
|
|
}
|
|
d.push_str(&format!("C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} ",
|
|
curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y));
|
|
}
|
|
d.push_str("Z");
|
|
svg2.push_str(&format!(
|
|
"<path d=\"{d}\" fill=\"rgba(255,0,0,0.08)\" stroke=\"red\" stroke-width=\"0.4\" stroke-dasharray=\"1,0.5\">\
|
|
<title>Face 15 stripped</title></path>\n",
|
|
));
|
|
}
|
|
|
|
// Also draw ALL non-exterior face boundaries so we can see the inner cycle
|
|
for (fi, face) in dcel.faces.iter().enumerate() {
|
|
if face.deleted || fi == 0 || face.outer_half_edge.is_none() { continue; }
|
|
if fi == 15 { continue; } // already drawn
|
|
let fid = FaceId(fi as u32);
|
|
let cycle = dcel.face_boundary(fid);
|
|
let stripped = dcel.strip_cycle(&cycle);
|
|
if stripped.is_empty() { continue; }
|
|
// Check if this face's stripped path contains P2
|
|
let fp = dcel.cycle_to_bezpath_stripped(&cycle);
|
|
let w = kurbo::Shape::winding(&fp, Point::new(253.2, 203.3));
|
|
if w == 0 { continue; } // Only draw faces that contain P2
|
|
let a = kurbo::Shape::area(&fp).abs();
|
|
let mut d = String::new();
|
|
for (j, &he_id) in stripped.iter().enumerate() {
|
|
let edge = &dcel.edges[dcel.half_edge(he_id).edge.idx()];
|
|
let is_fwd = edge.half_edges[0] == he_id;
|
|
let curve = if is_fwd {
|
|
edge.curve
|
|
} else {
|
|
CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0)
|
|
};
|
|
if j == 0 {
|
|
d.push_str(&format!("M{:.1},{:.1} ", curve.p0.x, curve.p0.y));
|
|
}
|
|
d.push_str(&format!("C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} ",
|
|
curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y));
|
|
}
|
|
d.push_str("Z");
|
|
svg2.push_str(&format!(
|
|
"<path d=\"{d}\" fill=\"rgba(0,128,255,0.1)\" stroke=\"blue\" stroke-width=\"0.3\" stroke-dasharray=\"1,0.5\">\
|
|
<title>Face {fi} (area={a:.0}, {}-edge stripped)</title></path>\n",
|
|
stripped.len(),
|
|
));
|
|
}
|
|
|
|
svg2.push_str("</svg>\n");
|
|
std::fs::write("/tmp/dcel_zoom.svg", &svg2).expect("write zoomed SVG");
|
|
eprintln!("Wrote /tmp/dcel_zoom.svg");
|
|
}
|
|
|
|
/// Minimal test to isolate bloated face bug from eight_strokes test.
|
|
#[test]
|
|
fn test_bloated_face_minimal() {
|
|
fn strip_spurs_len(dcel: &Dcel, cycle: &[HalfEdgeId]) -> usize {
|
|
let mut stripped: Vec<HalfEdgeId> = Vec::new();
|
|
for &he_id in cycle {
|
|
let edge = dcel.half_edge(he_id).edge;
|
|
if let Some(&top) = stripped.last() {
|
|
if dcel.half_edge(top).edge == edge { stripped.pop(); continue; }
|
|
}
|
|
stripped.push(he_id);
|
|
}
|
|
while stripped.len() >= 2 {
|
|
let fe = dcel.half_edge(stripped[0]).edge;
|
|
let le = dcel.half_edge(*stripped.last().unwrap()).edge;
|
|
if fe == le { stripped.pop(); stripped.remove(0); } else { break; }
|
|
}
|
|
if stripped.is_empty() { cycle.len() } else { stripped.len() }
|
|
}
|
|
fn max_stripped_cycle(dcel: &Dcel) -> (usize, usize) {
|
|
let mut worst = (0usize, 0usize);
|
|
for (i, face) in dcel.faces.iter().enumerate() {
|
|
if face.deleted || i == 0 || face.outer_half_edge.is_none() { continue; }
|
|
let cycle = dcel.face_boundary(FaceId(i as u32));
|
|
let n = strip_spurs_len(dcel, &cycle);
|
|
if n > worst.1 { worst = (i, n); }
|
|
}
|
|
worst
|
|
}
|
|
|
|
// Strokes 0-3 from seven_strokes test (stroke 0 simplified to first seg only)
|
|
// Eight strokes test data — reduce to find minimal reproduction
|
|
let strokes: Vec<Vec<CubicBez>> = vec![
|
|
vec![ // 0
|
|
CubicBez::new(Point::new(205.0, 366.2), Point::new(244.7, 255.0), Point::new(301.5, 184.3), Point::new(398.7, 119.5)),
|
|
CubicBez::new(Point::new(398.7, 119.5), Point::new(419.4, 105.7), Point::new(438.3, 87.0), Point::new(464.6, 87.0)),
|
|
],
|
|
vec![CubicBez::new(Point::new(131.7, 126.8), Point::new(278.6, 184.4), Point::new(420.9, 260.3), Point::new(570.1, 310.0))], // 1
|
|
vec![ // 2
|
|
CubicBez::new(Point::new(252.7, 369.6), Point::new(245.6, 297.8), Point::new(246.6, 225.3), Point::new(240.6, 153.5)),
|
|
CubicBez::new(Point::new(240.6, 153.5), Point::new(238.9, 132.9), Point::new(228.3, 112.7), Point::new(228.3, 92.0)),
|
|
],
|
|
vec![CubicBez::new(Point::new(362.6, 105.6), Point::new(317.6, 210.5), Point::new(160.1, 315.5), Point::new(149.0, 332.1))], // 3
|
|
vec![CubicBez::new(Point::new(134.6, 218.2), Point::new(228.4, 208.3), Point::new(368.1, 233.7), Point::new(458.8, 263.9))], // 4
|
|
vec![CubicBez::new(Point::new(329.0, 300.6), Point::new(339.5, 221.5), Point::new(342.3, 147.5), Point::new(316.7, 70.4))], // 5
|
|
vec![ // 6
|
|
CubicBez::new(Point::new(186.0, 99.2), Point::new(263.5, 118.6), Point::new(342.2, 129.8), Point::new(417.9, 156.3)),
|
|
CubicBez::new(Point::new(417.9, 156.3), Point::new(456.4, 169.8), Point::new(494.6, 191.3), Point::new(533.9, 201.1)),
|
|
],
|
|
vec![ // 7
|
|
CubicBez::new(Point::new(287.5, 73.5), Point::new(266.9, 135.2), Point::new(224.9, 188.7), Point::new(202.3, 251.0)),
|
|
CubicBez::new(Point::new(202.3, 251.0), Point::new(187.7, 291.0), Point::new(194.5, 335.7), Point::new(181.2, 375.8)),
|
|
],
|
|
];
|
|
|
|
// Per-stroke tracking (disabled to avoid DCEL_TRACE noise)
|
|
// let mut dcel = Dcel::new();
|
|
// for (i, s) in strokes.iter().enumerate() {
|
|
// dcel.insert_stroke(s, None, None, 5.0);
|
|
// let (f, c) = max_stripped_cycle(&dcel);
|
|
// eprintln!("After stroke {i}: worst stripped Face {f} cycle={c}");
|
|
// }
|
|
|
|
// Focus: strokes 0-3 create a face. Stroke 4 should split it but grows it.
|
|
fn dump_all_faces(d: &Dcel, label: &str) {
|
|
eprintln!("\n {label}:");
|
|
for (i, face) in d.faces.iter().enumerate() {
|
|
if face.deleted || i == 0 || face.outer_half_edge.is_none() { continue; }
|
|
let cycle = d.face_boundary(FaceId(i as u32));
|
|
let stripped_n = strip_spurs_len(d, &cycle);
|
|
eprintln!(" Face {i}: raw={} stripped={stripped_n}", cycle.len());
|
|
// Show stripped half-edges
|
|
let mut stripped: Vec<HalfEdgeId> = Vec::new();
|
|
for &he_id in &cycle {
|
|
let edge = d.half_edge(he_id).edge;
|
|
if let Some(&top) = stripped.last() {
|
|
if d.half_edge(top).edge == edge { stripped.pop(); continue; }
|
|
}
|
|
stripped.push(he_id);
|
|
}
|
|
while stripped.len() >= 2 {
|
|
let fe = d.half_edge(stripped[0]).edge;
|
|
let le = d.half_edge(*stripped.last().unwrap()).edge;
|
|
if fe == le { stripped.pop(); stripped.remove(0); } else { break; }
|
|
}
|
|
for (s, &he_id) in stripped.iter().enumerate() {
|
|
let he = d.half_edge(he_id);
|
|
let pos = d.vertices[he.origin.idx()].position;
|
|
let edge_data = d.edge(he.edge);
|
|
let dir = if edge_data.half_edges[0] == he_id { "fwd" } else { "bwd" };
|
|
let dest_he = d.half_edge(he.twin);
|
|
let dest_pos = d.vertices[dest_he.origin.idx()].position;
|
|
eprintln!(" [{s}] HE{} V{}({:.1},{:.1})->V{}({:.1},{:.1}) E{} {dir}",
|
|
he_id.0, he.origin.0, pos.x, pos.y,
|
|
dest_he.origin.0, dest_pos.x, dest_pos.y,
|
|
he.edge.0, );
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut d = Dcel::new();
|
|
for i in 0..3 {
|
|
d.insert_stroke(&strokes[i], None, None, 5.0);
|
|
}
|
|
dump_all_faces(&d, "After strokes 0-2");
|
|
|
|
let result3 = d.insert_stroke(&strokes[3], None, None, 5.0);
|
|
eprintln!("\nStroke 3 result: splits={} new_faces={:?} new_verts={:?}",
|
|
result3.split_edges.len(), result3.new_faces, result3.new_vertices);
|
|
dump_all_faces(&d, "After stroke 3");
|
|
|
|
// Dump vertex fans at key vertices before stroke 4
|
|
fn dump_vertex_fan(d: &Dcel, v: VertexId, label: &str) {
|
|
let pos = d.vertices[v.idx()].position;
|
|
eprintln!(" Vertex fan at V{}({:.1},{:.1}) {label}:", v.0, pos.x, pos.y);
|
|
let start = d.vertices[v.idx()].outgoing;
|
|
if start.is_none() { eprintln!(" (no edges)"); return; }
|
|
let mut cur = start;
|
|
loop {
|
|
let he = d.half_edge(cur);
|
|
let dest = d.half_edge(he.twin).origin;
|
|
let dest_pos = d.vertices[dest.idx()].position;
|
|
let angle = d.outgoing_angle(cur);
|
|
let face = he.face;
|
|
let edge = he.edge;
|
|
let edge_data = d.edge(edge);
|
|
let dir = if edge_data.half_edges[0] == cur { "fwd" } else { "bwd" };
|
|
eprintln!(" HE{} → V{}({:.1},{:.1}) E{} {} angle={:.3} face=F{}",
|
|
cur.0, dest.0, dest_pos.x, dest_pos.y, edge.0, dir, angle, face.0);
|
|
let twin = he.twin;
|
|
cur = d.half_edge(twin).next;
|
|
if cur == start { break; }
|
|
}
|
|
}
|
|
|
|
// Before stroke 4, dump the fan at key vertices
|
|
eprintln!("\n--- Before stroke 4 ---");
|
|
// Find all non-isolated vertices
|
|
for vi in 0..d.vertices.len() {
|
|
if d.vertices[vi].outgoing.is_none() { continue; }
|
|
dump_vertex_fan(&d, VertexId(vi as u32), "before stroke 4");
|
|
}
|
|
|
|
let result4 = d.insert_stroke(&strokes[4], None, None, 5.0);
|
|
eprintln!("\nStroke 4 result: splits={} new_faces={:?} new_verts={:?}",
|
|
result4.split_edges.len(), result4.new_faces, result4.new_vertices);
|
|
|
|
// After stroke 4, dump fans at vertices on the problematic face
|
|
eprintln!("\n--- After stroke 4 ---");
|
|
for vi in 0..d.vertices.len() {
|
|
if d.vertices[vi].outgoing.is_none() { continue; }
|
|
dump_vertex_fan(&d, VertexId(vi as u32), "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(®ion_segments, None, None, 1.0);
|
|
|
|
// Build the region path for extract_region
|
|
let mut region_path = BezPath::new();
|
|
region_path.move_to(Point::new(0.0, 0.0));
|
|
region_path.line_to(Point::new(150.0, 0.0));
|
|
region_path.line_to(Point::new(150.0, 150.0));
|
|
region_path.line_to(Point::new(0.0, 150.0));
|
|
region_path.close_path();
|
|
|
|
// Extract, then propagate fills on extracted only (remainder keeps
|
|
// its fills from the original data — no propagation needed there).
|
|
let mut extracted = dcel.extract_region(®ion_path, 1.0);
|
|
extracted.propagate_fills(&snapshot);
|
|
|
|
// The extracted DCEL should have at least one face with fill (the corner overlap)
|
|
let extracted_filled_faces: Vec<_> = extracted.faces.iter().enumerate()
|
|
.filter(|(i, f)| !f.deleted && *i > 0 && f.fill_color.is_some())
|
|
.collect();
|
|
assert!(
|
|
!extracted_filled_faces.is_empty(),
|
|
"Extracted DCEL should have at least one filled face (the corner overlap)"
|
|
);
|
|
|
|
// The original DCEL (remainder) should still have filled faces (the L-shaped remainder)
|
|
let remainder_filled_faces: Vec<_> = dcel.faces.iter().enumerate()
|
|
.filter(|(i, f)| !f.deleted && *i > 0 && f.fill_color.is_some())
|
|
.collect();
|
|
assert!(
|
|
!remainder_filled_faces.is_empty(),
|
|
"Remainder DCEL should have at least one filled face (L-shaped remainder)"
|
|
);
|
|
|
|
// The empty-space face in the remainder (outside the original rectangle)
|
|
// should NOT have fill — verify no spurious fill propagation
|
|
let point_outside_rect = Point::new(50.0, 50.0);
|
|
let face_at_outside = dcel.find_face_containing_point(point_outside_rect);
|
|
if face_at_outside.0 != 0 && !dcel.face(face_at_outside).deleted {
|
|
assert!(
|
|
dcel.face(face_at_outside).fill_color.is_none(),
|
|
"Face at (50,50) should NOT have fill — it's outside the original rectangle"
|
|
);
|
|
}
|
|
}
|
|
}
|