Rebuild DCEL after vector edits

This commit is contained in:
Skyler Lehmkuhl 2026-02-24 02:04:07 -05:00
parent 99f8dcfcf4
commit bcf6277329
8 changed files with 1459 additions and 859 deletions

View File

@ -259,7 +259,16 @@ fn dedup_intersections(intersections: &mut Vec<Intersection>, tolerance: f64) {
let mut j = i + 1;
while j < intersections.len() {
let dist = (intersections[i].point - intersections[j].point).hypot();
if dist < tolerance {
// Also check parameter distance — two intersections at the same
// spatial location but with very different t-values are distinct
// (e.g. a shared vertex vs. a real crossing nearby).
let t1_dist = (intersections[i].t1 - intersections[j].t1).abs();
let t2_dist = match (intersections[i].t2, intersections[j].t2) {
(Some(a), Some(b)) => (a - b).abs(),
_ => 0.0,
};
let param_close = t1_dist < 0.05 && t2_dist < 0.05;
if dist < tolerance && param_close {
intersections.remove(j);
} else {
j += 1;

View File

@ -5,7 +5,7 @@
//! maintained such that wherever two strokes intersect there is a vertex.
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
use kurbo::{BezPath, CubicBez, Point};
use kurbo::{BezPath, CubicBez, ParamCurveArclen, Point};
use rstar::{PointDistance, RTree, RTreeObject, AABB};
use serde::{Deserialize, Serialize};
use std::fmt;
@ -1036,9 +1036,11 @@ impl Dcel {
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: bwd_prev → fwd_prev
self.half_edges[bwd_prev.idx()].next = bwd_next;
self.half_edges[bwd_next.idx()].prev = bwd_prev;
// 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
@ -1046,9 +1048,11 @@ impl Dcel {
self.vertices[v1.idx()].outgoing = bwd_next;
}
} else if bwd_next == he_fwd {
// Similar spur in the other direction
self.half_edges[fwd_prev.idx()].next = fwd_next;
self.half_edges[fwd_next.idx()].prev = fwd_prev;
// 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;
@ -1071,18 +1075,33 @@ impl Dcel {
// Reassign all half-edges from dying face to surviving face
if surviving != dying && !dying.is_none() {
// Walk the remaining boundary of the dying face
// (After removal, the dying face's half-edges are now part of surviving)
if !self.faces[dying.idx()].outer_half_edge.is_none()
&& self.faces[dying.idx()].outer_half_edge != he_fwd
&& self.faces[dying.idx()].outer_half_edge != he_bwd
{
let start = self.faces[dying.idx()].outer_half_edge;
let mut cur = start;
// 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 == start {
if cur == walk_start {
break;
}
}
@ -1359,6 +1378,465 @@ impl Dcel {
result
}
// -----------------------------------------------------------------------
// 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;
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;
let intersections = find_curve_intersections(&edited_curve, &e.curve);
for inter in intersections {
if let Some(t2) = inter.t2 {
// Skip intersections where either t is too close to an
// endpoint to produce a usable split. The threshold is
// scaled by arc length so it corresponds to a consistent
// spatial tolerance. This filters:
// - Shared-vertex hits (both t near endpoints)
// - Spurious near-vertex bbox-overlap false positives
// - Hits that would create one-sided splits
if inter.t1 < t1_tol || inter.t1 > 1.0 - t1_tol
|| t2 < t2_tol || t2 > 1.0 - t2_tol
{
continue;
}
hits.push(Hit {
t_on_edited: inter.t1,
t_on_other: t2,
other_edge: other_id,
});
}
}
}
eprintln!("[DCEL] hits after filtering: {}", hits.len());
for h in &hits {
eprintln!(
"[DCEL] edge {:?} t_edited={:.6} t_other={:.6}",
h.other_edge, h.t_on_edited, h.t_on_other
);
}
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);
eprintln!(
"[DCEL] split other edge {:?} at t_in_current={:.6} (orig t={:.6}) → vtx {:?} pos={:?}",
current_edge, t_in_current, t_on_other, new_vertex,
self.vertices[new_vertex.idx()].position
);
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());
eprintln!("[DCEL] edited_edge_splits (sorted desc): {:?}", edited_edge_splits);
// 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);
eprintln!(
"[DCEL] split edited edge at t_in_current={:.6} (orig t={:.6}) → vtx {:?} pos={:?}, paired with {:?}",
t_in_current, t, new_vertex,
self.vertices[new_vertex.idx()].position,
other_vertex
);
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.
eprintln!("[DCEL] crossing_pairs: {:?}", crossing_pairs);
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 {
eprintln!("[DCEL] SKIP merge {:?} {:?} (deleted)", v_edited, v_other);
continue;
}
eprintln!(
"[DCEL] merging {:?} (pos={:?}) with {:?} (pos={:?})",
v_edited, self.vertices[v_edited.idx()].position,
v_other, self.vertices[v_other.idx()].position,
);
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();
}
// Dump final state
eprintln!("[DCEL] after recompute_edge_intersections:");
eprintln!("[DCEL] vertices: {}", self.vertices.iter().filter(|v| !v.deleted).count());
eprintln!("[DCEL] edges: {}", self.edges.iter().filter(|e| !e.deleted).count());
for (i, f) in self.faces.iter().enumerate() {
if !f.deleted {
let cycle_len = if !f.outer_half_edge.is_none() {
self.walk_cycle(f.outer_half_edge).len()
} else { 0 };
eprintln!("[DCEL] F{}: outer={:?} cycle_len={}", i, f.outer_half_edge, cycle_len);
}
}
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,
) {
// Re-home half-edges from v_remove → v_keep
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;
}
}
// 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;
}
}
}
/// Find which face contains a given point (brute force for now).
/// Returns FaceId(0) (unbounded) if no bounded face contains the point.
fn find_face_containing_point(&self, point: Point) -> FaceId {
@ -1737,4 +2215,151 @@ mod tests {
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);
}
}

View File

@ -1,12 +1,12 @@
//! Hit testing for selection and interaction
//!
//! Provides functions for testing if points or rectangles intersect with
//! shapes and objects, taking into account transform hierarchies.
//! DCEL elements and clip instances, taking into account transform hierarchies.
use crate::clip::ClipInstance;
use crate::dcel::{VertexId, EdgeId, FaceId};
use crate::layer::VectorLayer;
use crate::shape::Shape; // TODO: remove after DCEL migration complete
use crate::shape::Shape;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
@ -14,15 +14,25 @@ use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
/// Result of a hit test operation
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum HitResult {
/// Hit a shape instance
ShapeInstance(Uuid),
/// Hit a DCEL edge (stroke)
Edge(EdgeId),
/// Hit a DCEL face (fill)
Face(FaceId),
/// Hit a clip instance
ClipInstance(Uuid),
}
/// Hit test a layer at a specific point
/// Result of a DCEL-only hit test (no clip instances)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DcelHitResult {
Edge(EdgeId),
Face(FaceId),
}
/// Hit test a layer at a specific point, returning edge or face hits.
///
/// Tests shapes in the active keyframe in reverse order (front to back) and returns the first hit.
/// Tests DCEL edges (strokes) and faces (fills) in the active keyframe.
/// Edge hits take priority over face hits.
///
/// # Arguments
///
@ -34,15 +44,69 @@ pub enum HitResult {
///
/// # Returns
///
/// The UUID of the first shape hit, or None if no hit
/// The first DCEL element hit, or None if no hit
pub fn hit_test_layer(
_layer: &VectorLayer,
_time: f64,
_point: Point,
_tolerance: f64,
_parent_transform: Affine,
) -> Option<Uuid> {
// TODO: Implement DCEL-based hit testing (faces, edges, vertices)
layer: &VectorLayer,
time: f64,
point: Point,
tolerance: f64,
parent_transform: Affine,
) -> Option<DcelHitResult> {
let dcel = layer.dcel_at_time(time)?;
// Transform point to local space
let local_point = parent_transform.inverse() * point;
// 1. Check edges (strokes) — priority over faces
let mut best_edge: Option<(EdgeId, f64)> = None;
for (i, edge) in dcel.edges.iter().enumerate() {
if edge.deleted {
continue;
}
// Only hit-test edges that have a visible stroke
if edge.stroke_color.is_none() && edge.stroke_style.is_none() {
continue;
}
use kurbo::ParamCurveNearest;
let nearest = edge.curve.nearest(local_point, 0.5);
let dist = nearest.distance_sq.sqrt();
let hit_radius = edge
.stroke_style
.as_ref()
.map(|s| s.width / 2.0)
.unwrap_or(0.0)
+ tolerance;
if dist < hit_radius {
if best_edge.is_none() || dist < best_edge.unwrap().1 {
best_edge = Some((EdgeId(i as u32), dist));
}
}
}
if let Some((edge_id, _)) = best_edge {
return Some(DcelHitResult::Edge(edge_id));
}
// 2. Check faces (fills)
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
continue; // skip unbounded face
}
if face.fill_color.is_none() && face.image_fill.is_none() {
continue;
}
if face.outer_half_edge.is_none() {
continue;
}
let path = dcel.face_to_bezpath(FaceId(i as u32));
if path.winding(local_point) != 0 {
return Some(DcelHitResult::Face(FaceId(i as u32)));
}
}
None
}
@ -83,17 +147,73 @@ pub fn hit_test_shape(
false
}
/// Hit test objects within a rectangle (for marquee selection)
/// Result of DCEL marquee selection
#[derive(Debug, Default)]
pub struct DcelMarqueeResult {
pub edges: Vec<EdgeId>,
pub faces: Vec<FaceId>,
}
/// Hit test DCEL elements within a rectangle (for marquee selection).
///
/// Returns all shapes in the active keyframe whose bounding boxes intersect with the given rectangle.
pub fn hit_test_objects_in_rect(
_layer: &VectorLayer,
_time: f64,
_rect: Rect,
_parent_transform: Affine,
) -> Vec<Uuid> {
// TODO: Implement DCEL-based marquee selection
Vec::new()
/// Selects edges whose both endpoints are inside the rect,
/// and faces whose all boundary vertices are inside the rect.
pub fn hit_test_dcel_in_rect(
layer: &VectorLayer,
time: f64,
rect: Rect,
parent_transform: Affine,
) -> DcelMarqueeResult {
let mut result = DcelMarqueeResult::default();
let dcel = match layer.dcel_at_time(time) {
Some(d) => d,
None => return result,
};
let inv = parent_transform.inverse();
let local_rect = inv.transform_rect_bbox(rect);
// Check edges: both endpoints inside rect
for (i, edge) in dcel.edges.iter().enumerate() {
if edge.deleted {
continue;
}
let [he_fwd, he_bwd] = edge.half_edges;
if he_fwd.is_none() || he_bwd.is_none() {
continue;
}
let v1 = dcel.half_edge(he_fwd).origin;
let v2 = dcel.half_edge(he_bwd).origin;
if v1.is_none() || v2.is_none() {
continue;
}
let p1 = dcel.vertex(v1).position;
let p2 = dcel.vertex(v2).position;
if local_rect.contains(p1) && local_rect.contains(p2) {
result.edges.push(EdgeId(i as u32));
}
}
// Check faces: all boundary vertices inside rect
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
continue;
}
if face.outer_half_edge.is_none() {
continue;
}
let boundary = dcel.face_boundary(FaceId(i as u32));
let all_inside = boundary.iter().all(|&he_id| {
let v = dcel.half_edge(he_id).origin;
!v.is_none() && local_rect.contains(dcel.vertex(v).position)
});
if all_inside && !boundary.is_empty() {
result.faces.push(FaceId(i as u32));
}
}
result
}
/// Classification of shapes relative to a clipping region
@ -316,7 +436,7 @@ pub fn hit_test_vector_editing(
// Transform point into layer-local space
let local_point = parent_transform.inverse() * point;
// Priority: ControlPoint > Vertex > Curve
// Priority: ControlPoint > Vertex > Curve > Fill
// 1. Control points (only when show_control_points is true, e.g. BezierEdit tool)
if show_control_points {
@ -381,7 +501,23 @@ pub fn hit_test_vector_editing(
return Some(VectorEditHit::Curve { edge_id, parameter_t });
}
// 4. Face hit testing skipped for now
// 4. Face fill testing
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
continue;
}
if face.fill_color.is_none() && face.image_fill.is_none() {
continue;
}
if face.outer_half_edge.is_none() {
continue;
}
let path = dcel.face_to_bezpath(FaceId(i as u32));
if path.winding(local_point) != 0 {
return Some(VectorEditHit::Fill { face_id: FaceId(i as u32) });
}
}
None
}

View File

@ -1,24 +1,28 @@
//! Selection state management
//!
//! Tracks selected shape instances, clip instances, and shapes for editing operations.
//! Tracks selected DCEL elements (edges, faces, vertices) and clip instances for editing operations.
use crate::shape::Shape;
use crate::dcel::{Dcel, EdgeId, FaceId, VertexId};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
use vello::kurbo::BezPath;
/// Selection state for the editor
///
/// Maintains sets of selected shape instances, clip instances, and shapes.
/// This is separate from the document to make it easy to
/// pass around for UI rendering without needing mutable access.
/// Maintains sets of selected DCEL elements and clip instances.
/// The vertex/edge/face sets implicitly represent a subgraph of the DCEL —
/// connectivity is determined by shared vertices between edges.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Selection {
/// Currently selected shape instances
selected_shape_instances: Vec<Uuid>,
/// Currently selected vertices
selected_vertices: HashSet<VertexId>,
/// Currently selected shapes (definitions)
selected_shapes: Vec<Uuid>,
/// Currently selected edges
selected_edges: HashSet<EdgeId>,
/// Currently selected faces
selected_faces: HashSet<FaceId>,
/// Currently selected clip instances
selected_clip_instances: Vec<Uuid>,
@ -28,54 +32,168 @@ impl Selection {
/// Create a new empty selection
pub fn new() -> Self {
Self {
selected_shape_instances: Vec::new(),
selected_shapes: Vec::new(),
selected_vertices: HashSet::new(),
selected_edges: HashSet::new(),
selected_faces: HashSet::new(),
selected_clip_instances: Vec::new(),
}
}
/// Add a shape instance to the selection
pub fn add_shape_instance(&mut self, id: Uuid) {
if !self.selected_shape_instances.contains(&id) {
self.selected_shape_instances.push(id);
// -----------------------------------------------------------------------
// DCEL element selection
// -----------------------------------------------------------------------
/// Select an edge and its endpoint vertices, forming/extending a subgraph.
pub fn select_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
if edge_id.is_none() || dcel.edge(edge_id).deleted {
return;
}
self.selected_edges.insert(edge_id);
// Add both endpoint vertices
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
if !he_fwd.is_none() {
let v = dcel.half_edge(he_fwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
if !he_bwd.is_none() {
let v = dcel.half_edge(he_bwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
}
/// Add a shape definition to the selection
pub fn add_shape(&mut self, id: Uuid) {
if !self.selected_shapes.contains(&id) {
self.selected_shapes.push(id);
/// Select a face and all its boundary edges + vertices.
pub fn select_face(&mut self, face_id: FaceId, dcel: &Dcel) {
if face_id.is_none() || face_id.0 == 0 || dcel.face(face_id).deleted {
return;
}
self.selected_faces.insert(face_id);
// Add all boundary edges and vertices
let boundary = dcel.face_boundary(face_id);
for he_id in boundary {
let he = dcel.half_edge(he_id);
let edge_id = he.edge;
if !edge_id.is_none() {
self.selected_edges.insert(edge_id);
// Add endpoints
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
if !he_fwd.is_none() {
let v = dcel.half_edge(he_fwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
if !he_bwd.is_none() {
let v = dcel.half_edge(he_bwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
}
}
}
/// Remove a shape instance from the selection
pub fn remove_shape_instance(&mut self, id: &Uuid) {
self.selected_shape_instances.retain(|&x| x != *id);
/// Deselect an edge and its vertices (if they have no other selected edges).
pub fn deselect_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
self.selected_edges.remove(&edge_id);
// Remove endpoint vertices only if they're not used by other selected edges
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
for he_id in [he_fwd, he_bwd] {
if he_id.is_none() {
continue;
}
let v = dcel.half_edge(he_id).origin;
if v.is_none() {
continue;
}
// Check if any other selected edge uses this vertex
let used = self.selected_edges.iter().any(|&eid| {
let e = dcel.edge(eid);
let [a, b] = e.half_edges;
(!a.is_none() && dcel.half_edge(a).origin == v)
|| (!b.is_none() && dcel.half_edge(b).origin == v)
});
if !used {
self.selected_vertices.remove(&v);
}
}
}
/// Remove a shape definition from the selection
pub fn remove_shape(&mut self, id: &Uuid) {
self.selected_shapes.retain(|&x| x != *id);
/// Deselect a face (edges/vertices stay if still referenced by other selections).
pub fn deselect_face(&mut self, face_id: FaceId) {
self.selected_faces.remove(&face_id);
}
/// Toggle a shape instance's selection state
pub fn toggle_shape_instance(&mut self, id: Uuid) {
if self.contains_shape_instance(&id) {
self.remove_shape_instance(&id);
/// Toggle an edge's selection state.
pub fn toggle_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
if self.selected_edges.contains(&edge_id) {
self.deselect_edge(edge_id, dcel);
} else {
self.add_shape_instance(id);
self.select_edge(edge_id, dcel);
}
}
/// Toggle a shape's selection state
pub fn toggle_shape(&mut self, id: Uuid) {
if self.contains_shape(&id) {
self.remove_shape(&id);
/// Toggle a face's selection state.
pub fn toggle_face(&mut self, face_id: FaceId, dcel: &Dcel) {
if self.selected_faces.contains(&face_id) {
self.deselect_face(face_id);
} else {
self.add_shape(id);
self.select_face(face_id, dcel);
}
}
/// Check if an edge is selected.
pub fn contains_edge(&self, edge_id: &EdgeId) -> bool {
self.selected_edges.contains(edge_id)
}
/// Check if a face is selected.
pub fn contains_face(&self, face_id: &FaceId) -> bool {
self.selected_faces.contains(face_id)
}
/// Check if a vertex is selected.
pub fn contains_vertex(&self, vertex_id: &VertexId) -> bool {
self.selected_vertices.contains(vertex_id)
}
/// Clear DCEL element selections (edges, faces, vertices).
pub fn clear_dcel_selection(&mut self) {
self.selected_vertices.clear();
self.selected_edges.clear();
self.selected_faces.clear();
}
/// Check if any DCEL elements are selected.
pub fn has_dcel_selection(&self) -> bool {
!self.selected_edges.is_empty() || !self.selected_faces.is_empty()
}
/// Get selected edges.
pub fn selected_edges(&self) -> &HashSet<EdgeId> {
&self.selected_edges
}
/// Get selected faces.
pub fn selected_faces(&self) -> &HashSet<FaceId> {
&self.selected_faces
}
/// Get selected vertices.
pub fn selected_vertices(&self) -> &HashSet<VertexId> {
&self.selected_vertices
}
// -----------------------------------------------------------------------
// Clip instance selection (unchanged)
// -----------------------------------------------------------------------
/// Add a clip instance to the selection
pub fn add_clip_instance(&mut self, id: Uuid) {
if !self.selected_clip_instances.contains(&id) {
@ -97,68 +215,14 @@ impl Selection {
}
}
/// Clear all selections
pub fn clear(&mut self) {
self.selected_shape_instances.clear();
self.selected_shapes.clear();
self.selected_clip_instances.clear();
}
/// Clear only object selections
pub fn clear_shape_instances(&mut self) {
self.selected_shape_instances.clear();
}
/// Clear only shape selections
pub fn clear_shapes(&mut self) {
self.selected_shapes.clear();
}
/// Clear only clip instance selections
pub fn clear_clip_instances(&mut self) {
self.selected_clip_instances.clear();
}
/// Check if an object is selected
pub fn contains_shape_instance(&self, id: &Uuid) -> bool {
self.selected_shape_instances.contains(id)
}
/// Check if a shape is selected
pub fn contains_shape(&self, id: &Uuid) -> bool {
self.selected_shapes.contains(id)
}
/// Check if a clip instance is selected
pub fn contains_clip_instance(&self, id: &Uuid) -> bool {
self.selected_clip_instances.contains(id)
}
/// Check if selection is empty
pub fn is_empty(&self) -> bool {
self.selected_shape_instances.is_empty()
&& self.selected_shapes.is_empty()
&& self.selected_clip_instances.is_empty()
}
/// Get the selected objects
pub fn shape_instances(&self) -> &[Uuid] {
&self.selected_shape_instances
}
/// Get the selected shapes
pub fn shapes(&self) -> &[Uuid] {
&self.selected_shapes
}
/// Get the number of selected objects
pub fn shape_instance_count(&self) -> usize {
self.selected_shape_instances.len()
}
/// Get the number of selected shapes
pub fn shape_count(&self) -> usize {
self.selected_shapes.len()
/// Clear only clip instance selections
pub fn clear_clip_instances(&mut self) {
self.selected_clip_instances.clear();
}
/// Get the selected clip instances
@ -171,86 +235,61 @@ impl Selection {
self.selected_clip_instances.len()
}
/// Set selection to a single object (clears previous selection)
pub fn select_only_shape_instance(&mut self, id: Uuid) {
self.clear();
self.add_shape_instance(id);
}
/// Set selection to a single shape (clears previous selection)
pub fn select_only_shape(&mut self, id: Uuid) {
self.clear();
self.add_shape(id);
}
/// Set selection to a single clip instance (clears previous selection)
pub fn select_only_clip_instance(&mut self, id: Uuid) {
self.clear();
self.add_clip_instance(id);
}
/// Set selection to multiple objects (clears previous selection)
pub fn select_shape_instances(&mut self, ids: &[Uuid]) {
self.clear_shape_instances();
for &id in ids {
self.add_shape_instance(id);
}
}
/// Set selection to multiple shapes (clears previous selection)
pub fn select_shapes(&mut self, ids: &[Uuid]) {
self.clear_shapes();
for &id in ids {
self.add_shape(id);
}
}
/// Set selection to multiple clip instances (clears previous selection)
/// Set selection to multiple clip instances (clears previous clip selection)
pub fn select_clip_instances(&mut self, ids: &[Uuid]) {
self.clear_clip_instances();
for &id in ids {
self.add_clip_instance(id);
}
}
// -----------------------------------------------------------------------
// General
// -----------------------------------------------------------------------
/// Clear all selections
pub fn clear(&mut self) {
self.selected_vertices.clear();
self.selected_edges.clear();
self.selected_faces.clear();
self.selected_clip_instances.clear();
}
/// Check if selection is empty
pub fn is_empty(&self) -> bool {
self.selected_edges.is_empty()
&& self.selected_faces.is_empty()
&& self.selected_clip_instances.is_empty()
}
}
/// Represents a temporary region-based split of shapes.
/// Represents a temporary region-based selection.
///
/// When a region select is active, shapes that cross the region boundary
/// are temporarily split into "inside" and "outside" parts. The inside
/// parts are selected. If the user performs an operation, the split is
/// committed; if they deselect, the original shapes are restored.
/// When a region select is active, elements that cross the region boundary
/// are tracked. If the user performs an operation, the selection is
/// committed; if they deselect, the original state is restored.
#[derive(Clone, Debug)]
pub struct RegionSelection {
/// The clipping region as a closed BezPath (polygon or rect)
pub region_path: BezPath,
/// Layer containing the affected shapes
/// Layer containing the affected elements
pub layer_id: Uuid,
/// Keyframe time
pub time: f64,
/// Per-shape split results
pub splits: Vec<ShapeSplit>,
/// Shape IDs that were fully inside the region (not split, just selected)
/// Per-shape split results (legacy, kept for compatibility)
pub splits: Vec<()>,
/// IDs that were fully inside the region
pub fully_inside_ids: Vec<Uuid>,
/// Whether the split has been committed (via an operation on the selection)
/// Whether the selection has been committed (via an operation on the selection)
pub committed: bool,
}
/// One shape's split result from a region selection
#[derive(Clone, Debug)]
pub struct ShapeSplit {
/// The original shape (stored for reverting)
pub original_shape: Shape,
/// UUID for the "inside" portion shape
pub inside_shape_id: Uuid,
/// The clipped path inside the region
pub inside_path: BezPath,
/// UUID for the "outside" portion shape
pub outside_shape_id: Uuid,
/// The clipped path outside the region
pub outside_path: BezPath,
}
#[cfg(test)]
mod tests {
use super::*;
@ -259,67 +298,7 @@ mod tests {
fn test_selection_creation() {
let selection = Selection::new();
assert!(selection.is_empty());
assert_eq!(selection.shape_instance_count(), 0);
assert_eq!(selection.shape_count(), 0);
}
#[test]
fn test_add_remove_objects() {
let mut selection = Selection::new();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
selection.add_shape_instance(id1);
assert_eq!(selection.shape_instance_count(), 1);
assert!(selection.contains_shape_instance(&id1));
selection.add_shape_instance(id2);
assert_eq!(selection.shape_instance_count(), 2);
selection.remove_shape_instance(&id1);
assert_eq!(selection.shape_instance_count(), 1);
assert!(!selection.contains_shape_instance(&id1));
assert!(selection.contains_shape_instance(&id2));
}
#[test]
fn test_toggle() {
let mut selection = Selection::new();
let id = Uuid::new_v4();
selection.toggle_shape_instance(id);
assert!(selection.contains_shape_instance(&id));
selection.toggle_shape_instance(id);
assert!(!selection.contains_shape_instance(&id));
}
#[test]
fn test_select_only() {
let mut selection = Selection::new();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
selection.add_shape_instance(id1);
selection.add_shape_instance(id2);
assert_eq!(selection.shape_instance_count(), 2);
selection.select_only_shape_instance(id1);
assert_eq!(selection.shape_instance_count(), 1);
assert!(selection.contains_shape_instance(&id1));
assert!(!selection.contains_shape_instance(&id2));
}
#[test]
fn test_clear() {
let mut selection = Selection::new();
selection.add_shape_instance(Uuid::new_v4());
selection.add_shape(Uuid::new_v4());
assert!(!selection.is_empty());
selection.clear();
assert!(selection.is_empty());
assert_eq!(selection.clip_instance_count(), 0);
}
#[test]
@ -370,54 +349,34 @@ mod tests {
}
#[test]
fn test_clear_clip_instances() {
fn test_clear() {
let mut selection = Selection::new();
selection.add_clip_instance(Uuid::new_v4());
selection.add_clip_instance(Uuid::new_v4());
selection.add_shape_instance(Uuid::new_v4());
assert_eq!(selection.clip_instance_count(), 2);
assert_eq!(selection.shape_instance_count(), 1);
selection.clear_clip_instances();
assert_eq!(selection.clip_instance_count(), 0);
assert_eq!(selection.shape_instance_count(), 1);
}
#[test]
fn test_clip_instances_getter() {
let mut selection = Selection::new();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
selection.add_clip_instance(id1);
selection.add_clip_instance(id2);
let clip_instances = selection.clip_instances();
assert_eq!(clip_instances.len(), 2);
assert!(clip_instances.contains(&id1));
assert!(clip_instances.contains(&id2));
}
#[test]
fn test_mixed_selection() {
let mut selection = Selection::new();
let shape_instance_id = Uuid::new_v4();
let clip_instance_id = Uuid::new_v4();
selection.add_shape_instance(shape_instance_id);
selection.add_clip_instance(clip_instance_id);
assert_eq!(selection.shape_instance_count(), 1);
assert_eq!(selection.clip_instance_count(), 1);
assert!(!selection.is_empty());
selection.clear_shape_instances();
assert_eq!(selection.shape_instance_count(), 0);
assert_eq!(selection.clip_instance_count(), 1);
assert!(!selection.is_empty());
selection.clear();
assert!(selection.is_empty());
}
#[test]
fn test_dcel_selection_basics() {
let selection = Selection::new();
assert!(!selection.has_dcel_selection());
assert!(selection.selected_edges().is_empty());
assert!(selection.selected_faces().is_empty());
assert!(selection.selected_vertices().is_empty());
}
#[test]
fn test_clear_dcel_selection() {
let mut selection = Selection::new();
// Manually insert for unit test (no DCEL needed)
selection.selected_edges.insert(EdgeId(0));
selection.selected_vertices.insert(VertexId(0));
assert!(selection.has_dcel_selection());
selection.clear_dcel_selection();
assert!(!selection.has_dcel_selection());
}
}

View File

@ -130,6 +130,13 @@ pub enum ToolState {
parameter_t: f64,
},
/// Pending curve interaction: click selects edge, drag starts curve editing
PendingCurveInteraction {
edge_id: crate::dcel::EdgeId,
parameter_t: f64,
start_mouse: Point,
},
/// Drawing a region selection rectangle
RegionSelectingRect {
start: Point,

View File

@ -1658,37 +1658,8 @@ impl EditorApp {
};
self.clipboard_manager.copy(content);
} else if !self.selection.shape_instances().is_empty() {
let active_layer_id = match self.active_layer_id {
Some(id) => id,
None => return,
};
let document = self.action_executor.document();
let layer = match document.get_layer(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
// Gather selected shapes (they now contain their own transforms)
let selected_shapes: Vec<_> = self.selection.shapes().iter()
.filter_map(|id| vector_layer.shapes.get(id).cloned())
.collect();
if selected_shapes.is_empty() {
return;
}
let content = ClipboardContent::Shapes {
shapes: selected_shapes,
};
self.clipboard_manager.copy(content);
} else if self.selection.has_dcel_selection() {
// TODO: DCEL copy/paste deferred to Phase 2 (serialize subgraph)
}
}
@ -1736,26 +1707,45 @@ impl EditorApp {
}
self.selection.clear_clip_instances();
} else if !self.selection.shapes().is_empty() {
} else if self.selection.has_dcel_selection() {
let active_layer_id = match self.active_layer_id {
Some(id) => id,
None => return,
};
let shape_ids: Vec<Uuid> = self.selection.shapes().to_vec();
// Delete selected edges via snapshot-based ModifyDcelAction
let edge_ids: Vec<lightningbeam_core::dcel::EdgeId> =
self.selection.selected_edges().iter().copied().collect();
let action = lightningbeam_core::actions::RemoveShapesAction::new(
active_layer_id,
shape_ids,
self.playback_time,
);
if !edge_ids.is_empty() {
let document = self.action_executor.document();
if let Some(layer) = document.get_layer(&active_layer_id) {
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
if let Some(dcel_before) = vector_layer.dcel_at_time(self.playback_time) {
let mut dcel_after = dcel_before.clone();
for edge_id in &edge_ids {
if !dcel_after.edge(*edge_id).deleted {
dcel_after.remove_edge(*edge_id);
}
}
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Delete shapes failed: {}", e);
let action = lightningbeam_core::actions::ModifyDcelAction::new(
active_layer_id,
self.playback_time,
dcel_before.clone(),
dcel_after,
"Delete selected edges",
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Delete DCEL edges failed: {}", e);
}
}
}
}
}
self.selection.clear_shape_instances();
self.selection.clear_shapes();
self.selection.clear_dcel_selection();
}
}
@ -1885,17 +1875,9 @@ impl EditorApp {
}
};
let new_shape_ids: Vec<Uuid> = shapes.iter().map(|s| s.id).collect();
// TODO: DCEL - paste shapes disabled during migration
// (was: push shapes into kf.shapes)
// (was: push shapes into kf.shapes, select pasted shapes)
let _ = (vector_layer, shapes);
// Select pasted shapes
self.selection.clear_shapes();
for id in new_shape_ids {
self.selection.add_shape(id);
}
}
ClipboardContent::MidiNotes { .. } => {
// MIDI notes are pasted directly in the piano roll pane, not here
@ -2426,44 +2408,51 @@ impl EditorApp {
// Modify menu
MenuAction::Group => {
if let Some(layer_id) = self.active_layer_id {
let shape_ids: Vec<uuid::Uuid> = self.selection.shape_instances().to_vec();
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
if shape_ids.len() + clip_ids.len() >= 2 {
let instance_id = uuid::Uuid::new_v4();
let action = lightningbeam_core::actions::GroupAction::new(
layer_id,
self.playback_time,
shape_ids,
clip_ids,
instance_id,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to group: {}", e);
} else {
self.selection.clear();
self.selection.add_clip_instance(instance_id);
if self.selection.has_dcel_selection() {
// TODO: DCEL group deferred to Phase 2 (extract subgraph)
} else {
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
if clip_ids.len() >= 2 {
let instance_id = uuid::Uuid::new_v4();
let action = lightningbeam_core::actions::GroupAction::new(
layer_id,
self.playback_time,
Vec::new(),
clip_ids,
instance_id,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to group: {}", e);
} else {
self.selection.clear();
self.selection.add_clip_instance(instance_id);
}
}
}
let _ = layer_id;
}
}
MenuAction::ConvertToMovieClip => {
if let Some(layer_id) = self.active_layer_id {
let shape_ids: Vec<uuid::Uuid> = self.selection.shape_instances().to_vec();
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
if shape_ids.len() + clip_ids.len() >= 1 {
let instance_id = uuid::Uuid::new_v4();
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
layer_id,
self.playback_time,
shape_ids,
clip_ids,
instance_id,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to convert to movie clip: {}", e);
} else {
self.selection.clear();
self.selection.add_clip_instance(instance_id);
if self.selection.has_dcel_selection() {
// TODO: DCEL convert-to-movie-clip deferred to Phase 2
} else {
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
if clip_ids.len() >= 1 {
let instance_id = uuid::Uuid::new_v4();
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
layer_id,
self.playback_time,
Vec::new(),
clip_ids,
instance_id,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to convert to movie clip: {}", e);
} else {
self.selection.clear();
self.selection.add_clip_instance(instance_id);
}
}
}
}

View File

@ -6,11 +6,8 @@
/// - Shape properties (fill/stroke for selected shapes)
/// - Document settings (when nothing is selected)
use eframe::egui::{self, DragValue, Sense, Ui};
use lightningbeam_core::actions::{
InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction,
SetShapePropertiesAction,
};
use eframe::egui::{self, DragValue, Ui};
use lightningbeam_core::actions::SetDocumentPropertiesAction;
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::shape::ShapeColor;
use lightningbeam_core::tool::{SimplifyMode, Tool};
@ -21,8 +18,6 @@ use uuid::Uuid;
pub struct InfopanelPane {
/// Whether the tool options section is expanded
tool_section_open: bool,
/// Whether the transform section is expanded
transform_section_open: bool,
/// Whether the shape properties section is expanded
shape_section_open: bool,
}
@ -31,7 +26,6 @@ impl InfopanelPane {
pub fn new() -> Self {
Self {
tool_section_open: true,
transform_section_open: true,
shape_section_open: true,
}
}
@ -41,24 +35,10 @@ impl InfopanelPane {
struct SelectionInfo {
/// True if nothing is selected
is_empty: bool,
/// Number of selected shape instances
shape_count: usize,
/// Layer ID of selected shapes (assumes single layer selection for now)
/// Number of selected DCEL elements (edges + faces)
dcel_count: usize,
/// Layer ID of selected elements (assumes single layer selection for now)
layer_id: Option<Uuid>,
/// Selected shape instance IDs
instance_ids: Vec<Uuid>,
/// Shape IDs referenced by selected instances
shape_ids: Vec<Uuid>,
// Transform values (None = mixed values across selection)
x: Option<f64>,
y: Option<f64>,
rotation: Option<f64>,
scale_x: Option<f64>,
scale_y: Option<f64>,
skew_x: Option<f64>,
skew_y: Option<f64>,
opacity: Option<f64>,
// Shape property values (None = mixed)
fill_color: Option<Option<ShapeColor>>,
@ -70,18 +50,8 @@ impl Default for SelectionInfo {
fn default() -> Self {
Self {
is_empty: true,
shape_count: 0,
dcel_count: 0,
layer_id: None,
instance_ids: Vec::new(),
shape_ids: Vec::new(),
x: None,
y: None,
rotation: None,
scale_x: None,
scale_y: None,
skew_x: None,
skew_y: None,
opacity: None,
fill_color: None,
stroke_color: None,
stroke_width: None,
@ -94,17 +64,15 @@ impl InfopanelPane {
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
let mut info = SelectionInfo::default();
let selected_instances = shared.selection.shape_instances();
info.shape_count = selected_instances.len();
info.is_empty = info.shape_count == 0;
let edge_count = shared.selection.selected_edges().len();
let face_count = shared.selection.selected_faces().len();
info.dcel_count = edge_count + face_count;
info.is_empty = info.dcel_count == 0;
if info.is_empty {
return info;
}
info.instance_ids = selected_instances.to_vec();
// Find the layer containing the selected instances
let document = shared.action_executor.document();
let active_layer_id = *shared.active_layer_id;
@ -113,10 +81,56 @@ impl InfopanelPane {
if let Some(layer) = document.get_layer(&layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
// Gather values from all selected instances
// TODO: DCEL - shape property gathering disabled during migration
// (was: get_shape_in_keyframe to gather transform/fill/stroke properties)
let _ = vector_layer;
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
// Gather stroke properties from selected edges
let mut first_stroke_color: Option<Option<ShapeColor>> = None;
let mut first_stroke_width: Option<f64> = None;
let mut stroke_color_mixed = false;
let mut stroke_width_mixed = false;
for &eid in shared.selection.selected_edges() {
let edge = dcel.edge(eid);
let sc = edge.stroke_color;
let sw = edge.stroke_style.as_ref().map(|s| s.width);
match first_stroke_color {
None => first_stroke_color = Some(sc),
Some(prev) if prev != sc => stroke_color_mixed = true,
_ => {}
}
match (first_stroke_width, sw) {
(None, _) => first_stroke_width = sw,
(Some(prev), Some(cur)) if (prev - cur).abs() > 0.01 => stroke_width_mixed = true,
_ => {}
}
}
if !stroke_color_mixed {
info.stroke_color = first_stroke_color;
}
if !stroke_width_mixed {
info.stroke_width = first_stroke_width;
}
// Gather fill properties from selected faces
let mut first_fill_color: Option<Option<ShapeColor>> = None;
let mut fill_color_mixed = false;
for &fid in shared.selection.selected_faces() {
let face = dcel.face(fid);
let fc = face.fill_color;
match first_fill_color {
None => first_fill_color = Some(fc),
Some(prev) if prev != fc => fill_color_mixed = true,
_ => {}
}
}
if !fill_color_mixed {
info.fill_color = first_fill_color;
}
}
}
}
}
@ -262,214 +276,14 @@ impl InfopanelPane {
});
}
/// Render transform properties section
fn render_transform_section(
&mut self,
ui: &mut Ui,
path: &NodePath,
shared: &mut SharedPaneState,
info: &SelectionInfo,
) {
egui::CollapsingHeader::new("Transform")
.id_salt(("transform", path))
.default_open(self.transform_section_open)
.show(ui, |ui| {
self.transform_section_open = true;
ui.add_space(4.0);
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
// Position X
self.render_transform_field(
ui,
"X:",
info.x,
1.0,
f64::NEG_INFINITY..=f64::INFINITY,
|value| InstancePropertyChange::X(value),
layer_id,
&info.instance_ids,
shared,
);
// Position Y
self.render_transform_field(
ui,
"Y:",
info.y,
1.0,
f64::NEG_INFINITY..=f64::INFINITY,
|value| InstancePropertyChange::Y(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Rotation
self.render_transform_field(
ui,
"Rotation:",
info.rotation,
1.0,
-360.0..=360.0,
|value| InstancePropertyChange::Rotation(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Scale X
self.render_transform_field(
ui,
"Scale X:",
info.scale_x,
0.01,
0.01..=100.0,
|value| InstancePropertyChange::ScaleX(value),
layer_id,
&info.instance_ids,
shared,
);
// Scale Y
self.render_transform_field(
ui,
"Scale Y:",
info.scale_y,
0.01,
0.01..=100.0,
|value| InstancePropertyChange::ScaleY(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Skew X
self.render_transform_field(
ui,
"Skew X:",
info.skew_x,
1.0,
-89.0..=89.0,
|value| InstancePropertyChange::SkewX(value),
layer_id,
&info.instance_ids,
shared,
);
// Skew Y
self.render_transform_field(
ui,
"Skew Y:",
info.skew_y,
1.0,
-89.0..=89.0,
|value| InstancePropertyChange::SkewY(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Opacity
self.render_transform_field(
ui,
"Opacity:",
info.opacity,
0.01,
0.0..=1.0,
|value| InstancePropertyChange::Opacity(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
});
}
/// Render a single transform property field with drag-to-adjust
fn render_transform_field<F>(
&self,
ui: &mut Ui,
label: &str,
value: Option<f64>,
speed: f64,
range: std::ops::RangeInclusive<f64>,
make_change: F,
layer_id: Uuid,
instance_ids: &[Uuid],
shared: &mut SharedPaneState,
) where
F: Fn(f64) -> InstancePropertyChange,
{
ui.horizontal(|ui| {
// Label with drag sense for drag-to-adjust
let label_response = ui.add(egui::Label::new(label).sense(Sense::drag()));
match value {
Some(mut v) => {
// Handle drag on label
if label_response.dragged() {
let delta = label_response.drag_delta().x as f64 * speed;
v = (v + delta).clamp(*range.start(), *range.end());
// Create action for each selected instance
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*shared.playback_time,
*instance_id,
make_change(v),
);
shared.pending_actions.push(Box::new(action));
}
}
// DragValue widget
let response = ui.add(
DragValue::new(&mut v)
.speed(speed)
.range(range.clone()),
);
if response.changed() {
// Create action for each selected instance
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*shared.playback_time,
*instance_id,
make_change(v),
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
// Mixed values - show placeholder
ui.label("--");
}
}
});
}
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
/// Render shape properties section (fill/stroke)
fn render_shape_section(
&mut self,
ui: &mut Ui,
path: &NodePath,
shared: &mut SharedPaneState,
_shared: &mut SharedPaneState,
info: &SelectionInfo,
) {
egui::CollapsingHeader::new("Shape")
@ -479,54 +293,22 @@ impl InfopanelPane {
self.shape_section_open = true;
ui.add_space(4.0);
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
// Fill color
// Fill color (read-only display for now)
ui.horizontal(|ui| {
ui.label("Fill:");
match info.fill_color {
Some(Some(color)) => {
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
let egui_color = egui::Color32::from_rgba_unmultiplied(
color.r, color.g, color.b, color.a,
);
if ui.color_edit_button_srgba(&mut egui_color).changed() {
let new_color = Some(ShapeColor::new(
egui_color.r(),
egui_color.g(),
egui_color.b(),
egui_color.a(),
));
// Create action for each selected shape
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
*shared.playback_time,
new_color,
);
shared.pending_actions.push(Box::new(action));
}
}
let (rect, _) = ui.allocate_exact_size(
egui::vec2(20.0, 20.0),
egui::Sense::hover(),
);
ui.painter().rect_filled(rect, 2.0, egui_color);
}
Some(None) => {
if ui.button("Add Fill").clicked() {
// Add default black fill
let default_fill = Some(ShapeColor::rgb(0, 0, 0));
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
*shared.playback_time,
default_fill,
);
shared.pending_actions.push(Box::new(action));
}
}
ui.label("None");
}
None => {
ui.label("--");
@ -534,49 +316,22 @@ impl InfopanelPane {
}
});
// Stroke color
// Stroke color (read-only display for now)
ui.horizontal(|ui| {
ui.label("Stroke:");
match info.stroke_color {
Some(Some(color)) => {
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
let egui_color = egui::Color32::from_rgba_unmultiplied(
color.r, color.g, color.b, color.a,
);
if ui.color_edit_button_srgba(&mut egui_color).changed() {
let new_color = Some(ShapeColor::new(
egui_color.r(),
egui_color.g(),
egui_color.b(),
egui_color.a(),
));
// Create action for each selected shape
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
*shared.playback_time,
new_color,
);
shared.pending_actions.push(Box::new(action));
}
}
let (rect, _) = ui.allocate_exact_size(
egui::vec2(20.0, 20.0),
egui::Sense::hover(),
);
ui.painter().rect_filled(rect, 2.0, egui_color);
}
Some(None) => {
if ui.button("Add Stroke").clicked() {
// Add default black stroke
let default_stroke = Some(ShapeColor::rgb(0, 0, 0));
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
*shared.playback_time,
default_stroke,
);
shared.pending_actions.push(Box::new(action));
}
}
ui.label("None");
}
None => {
ui.label("--");
@ -584,28 +339,12 @@ impl InfopanelPane {
}
});
// Stroke width
// Stroke width (read-only display for now)
ui.horizontal(|ui| {
ui.label("Stroke Width:");
match info.stroke_width {
Some(mut width) => {
let response = ui.add(
DragValue::new(&mut width)
.speed(0.1)
.range(0.1..=100.0),
);
if response.changed() {
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_width(
layer_id,
*shape_id,
*shared.playback_time,
width,
);
shared.pending_actions.push(Box::new(action));
}
}
Some(width) => {
ui.label(format!("{:.1}", width));
}
None => {
ui.label("--");
@ -737,13 +476,8 @@ impl PaneRenderer for InfopanelPane {
// 2. Gather selection info
let info = self.gather_selection_info(shared);
// 3. Transform section (if shapes selected)
if info.shape_count > 0 {
self.render_transform_section(ui, path, shared, &info);
}
// 4. Shape properties section (if shapes selected)
if info.shape_count > 0 {
// 3. Shape properties section (if DCEL elements selected)
if info.dcel_count > 0 {
self.render_shape_section(ui, path, shared, &info);
}
@ -753,14 +487,14 @@ impl PaneRenderer for InfopanelPane {
}
// Show selection count at bottom
if info.shape_count > 0 {
if info.dcel_count > 0 {
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
ui.label(format!(
"{} object{} selected",
info.shape_count,
if info.shape_count == 1 { "" } else { "s" }
info.dcel_count,
if info.dcel_count == 1 { "" } else { "s" }
));
}
});

View File

@ -386,6 +386,8 @@ struct VelloRenderContext {
editing_parent_layer_id: Option<uuid::Uuid>,
/// Active region selection state (for rendering boundary overlay)
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
/// Mouse position in document-local (clip-local) world coordinates, for hover hit testing
mouse_world_pos: Option<vello::kurbo::Point>,
}
/// Callback for Vello rendering within egui
@ -887,11 +889,54 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
let selection_color = Color::from_rgb8(0, 120, 255); // Blue
let stroke_width = 2.0 / self.ctx.zoom.max(0.5) as f64;
// 1. Draw selection outlines around selected objects
// 1. Draw selection stipple overlay on selected DCEL elements + clip outlines
// NOTE: Skip this if Transform tool is active (it has its own handles)
if !self.ctx.selection.is_empty() && !matches!(self.ctx.selected_tool, Tool::Transform) {
// TODO: DCEL - shape selection outlines disabled during migration
// (was: iterate shape_instances, get_shape_in_keyframe, draw bbox outlines)
// Draw Flash-style stipple pattern on selected edges and faces
if self.ctx.selection.has_dcel_selection() {
if let Some(dcel) = vector_layer.dcel_at_time(self.ctx.playback_time) {
let stipple_brush = selection_stipple_brush();
// brush_transform scales the stipple so 1 pattern pixel = 1 screen pixel.
// The shape is in document space, transformed to screen by overlay_transform
// (which includes zoom). The brush tiles in document space by default,
// so we scale it by 1/zoom to make each 2x2 tile = 2x2 screen pixels.
let inv_zoom = 1.0 / self.ctx.zoom as f64;
let brush_xform = Some(Affine::scale(inv_zoom));
// Stipple selected faces
for &face_id in self.ctx.selection.selected_faces() {
let face = dcel.face(face_id);
if face.deleted || face_id.0 == 0 { continue; }
let path = dcel.face_to_bezpath_with_holes(face_id);
scene.fill(
Fill::NonZero,
overlay_transform,
stipple_brush,
brush_xform,
&path,
);
}
// Stipple selected edges
for &edge_id in self.ctx.selection.selected_edges() {
let edge = dcel.edge(edge_id);
if edge.deleted { continue; }
let width = edge.stroke_style.as_ref()
.map(|s| s.width)
.unwrap_or(2.0);
let mut path = vello::kurbo::BezPath::new();
path.move_to(edge.curve.p0);
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
scene.stroke(
&Stroke::new(width),
overlay_transform,
stipple_brush,
brush_xform,
&path,
);
}
}
}
// Also draw selection outlines for clip instances
for &clip_id in self.ctx.selection.clip_instances() {
@ -962,6 +1007,65 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
}
}
// 1b. Draw stipple hover highlight on the curve under the mouse
// During active curve editing, lock highlight to the edited curve
if matches!(self.ctx.selected_tool, Tool::Select | Tool::BezierEdit) {
use lightningbeam_core::tool::ToolState;
// Determine which edge to highlight: active edit takes priority over hover
let highlight_edge = match &self.ctx.tool_state {
ToolState::EditingCurve { edge_id, .. }
| ToolState::PendingCurveInteraction { edge_id, .. } => {
Some(*edge_id)
}
_ => {
// Fall back to hover hit test
self.ctx.mouse_world_pos.and_then(|mouse_pos| {
use lightningbeam_core::hit_test::{hit_test_vector_editing, EditingHitTolerance, VectorEditHit};
let is_bezier = matches!(self.ctx.selected_tool, Tool::BezierEdit);
let tolerance = EditingHitTolerance::scaled_by_zoom(self.ctx.zoom as f64);
let hit = hit_test_vector_editing(
vector_layer,
self.ctx.playback_time,
mouse_pos,
&tolerance,
Affine::IDENTITY,
is_bezier,
);
match hit {
Some(VectorEditHit::Curve { edge_id, .. }) => Some(edge_id),
_ => None,
}
})
}
};
if let Some(edge_id) = highlight_edge {
if let Some(dcel) = vector_layer.dcel_at_time(self.ctx.playback_time) {
let edge = dcel.edge(edge_id);
if !edge.deleted {
let stipple_brush = selection_stipple_brush();
let inv_zoom = 1.0 / self.ctx.zoom as f64;
let brush_xform = Some(Affine::scale(inv_zoom));
let width = edge.stroke_style.as_ref()
.map(|s| s.width + 4.0)
.unwrap_or(3.0)
.max(3.0);
let mut path = vello::kurbo::BezPath::new();
path.move_to(edge.curve.p0);
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
scene.stroke(
&Stroke::new(width),
overlay_transform,
stipple_brush,
brush_xform,
&path,
);
}
}
}
}
// 2. Draw marquee selection rectangle
if let lightningbeam_core::tool::ToolState::MarqueeSelecting { ref start, ref current } = self.ctx.tool_state {
let marquee_rect = KurboRect::new(
@ -1371,14 +1475,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// For single object: use object-aligned (rotated) bounding box
// For multiple objects: use axis-aligned bounding box (simpler for now)
let total_selected = self.ctx.selection.shape_instances().len() + self.ctx.selection.clip_instances().len();
let total_selected = self.ctx.selection.clip_instances().len();
if total_selected == 1 {
// Single object - draw rotated bounding box
let object_id = if let Some(&id) = self.ctx.selection.shape_instances().iter().next() {
id
} else {
*self.ctx.selection.clip_instances().iter().next().unwrap()
};
// Single clip instance - draw rotated bounding box
let object_id = *self.ctx.selection.clip_instances().iter().next().unwrap();
// TODO: DCEL - single-object transform handles disabled during migration
// (was: get_shape_in_keyframe for rotated bbox + handle drawing)
@ -1921,6 +2021,36 @@ static INSTANCE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::Atomi
// Global storage for eyedropper results (instance_id -> (color, color_mode))
static EYEDROPPER_RESULTS: OnceLock<Arc<Mutex<std::collections::HashMap<u64, (egui::Color32, super::ColorMode)>>>> = OnceLock::new();
/// Cached 2x2 stipple image brush for selection overlay.
/// Pattern: [[black, transparent], [transparent, white]]
/// Tiled with nearest-neighbor sampling so each pixel stays crisp.
static SELECTION_STIPPLE: OnceLock<vello::peniko::ImageBrush> = OnceLock::new();
fn selection_stipple_brush() -> &'static vello::peniko::ImageBrush {
SELECTION_STIPPLE.get_or_init(|| {
use vello::peniko::{Blob, Extend, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality};
// 2x2 RGBA pixels: row-major order
// [0,0] = black opaque, [1,0] = transparent
// [0,1] = transparent, [1,1] = white opaque
let pixels: Vec<u8> = vec![
0, 0, 0, 255, // (0,0) black
0, 0, 0, 0, // (1,0) transparent
0, 0, 0, 0, // (0,1) transparent
255, 255, 255, 255, // (1,1) white
];
let image_data = ImageData {
data: Blob::from(pixels),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: 2,
height: 2,
};
ImageBrush::new(image_data)
.with_extend(Extend::Repeat)
.with_quality(ImageQuality::Low)
})
}
impl StagePane {
pub fn new() -> Self {
let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
@ -2139,7 +2269,7 @@ impl StagePane {
Affine::IDENTITY,
false, // Select tool doesn't show control points
);
// Priority 1: Vector editing (vertices and curves)
// Priority 1: Vector editing (vertices immediately, curves deferred)
if let Some(hit) = vector_hit {
match hit {
VectorEditHit::Vertex { vertex_id } => {
@ -2147,7 +2277,12 @@ impl StagePane {
return;
}
VectorEditHit::Curve { edge_id, parameter_t } => {
self.start_curve_editing(edge_id, parameter_t, point, active_layer_id, shared);
// Defer: drag → curve editing, click → edge selection
*shared.tool_state = ToolState::PendingCurveInteraction {
edge_id,
parameter_t,
start_mouse: point,
};
return;
}
_ => {
@ -2171,38 +2306,39 @@ impl StagePane {
let hit_result = if let Some(clip_id) = clip_hit {
Some(hit_test::HitResult::ClipInstance(clip_id))
} else {
// No clip hit, test shape instances
// No clip hit, test DCEL edges and faces
hit_test::hit_test_layer(vector_layer, *shared.playback_time, point, 5.0, Affine::IDENTITY)
.map(|id| hit_test::HitResult::ShapeInstance(id))
.map(|dcel_hit| match dcel_hit {
hit_test::DcelHitResult::Edge(eid) => hit_test::HitResult::Edge(eid),
hit_test::DcelHitResult::Face(fid) => hit_test::HitResult::Face(fid),
})
};
if let Some(hit) = hit_result {
match hit {
hit_test::HitResult::ShapeInstance(object_id) => {
// Shape instance was hit
if shift_held {
// Shift: toggle selection
shared.selection.toggle_shape_instance(object_id);
} else {
// No shift: replace selection
if !shared.selection.contains_shape_instance(&object_id) {
shared.selection.select_only_shape_instance(object_id);
hit_test::HitResult::Edge(edge_id) => {
// DCEL edge was hit
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
if shift_held {
shared.selection.toggle_edge(edge_id, dcel);
} else {
shared.selection.clear_dcel_selection();
shared.selection.select_edge(edge_id, dcel);
}
}
// If object is now selected, prepare for dragging
if shared.selection.contains_shape_instance(&object_id) {
// Store original positions of all selected objects
let original_positions = std::collections::HashMap::new();
// TODO: DCEL - shape position lookup disabled during migration
// (was: get_shape_in_keyframe to store original positions for drag)
*shared.tool_state = ToolState::DraggingSelection {
start_pos: point,
start_mouse: point,
original_positions,
};
// DCEL element dragging deferred to Phase 3
}
hit_test::HitResult::Face(face_id) => {
// DCEL face was hit
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
if shift_held {
shared.selection.toggle_face(face_id, dcel);
} else {
shared.selection.clear_dcel_selection();
shared.selection.select_face(face_id, dcel);
}
}
// DCEL element dragging deferred to Phase 3
}
hit_test::HitResult::ClipInstance(clip_id) => {
// Clip instance was hit
@ -2255,6 +2391,14 @@ impl StagePane {
// Mouse drag: update tool state
if response.dragged() {
match shared.tool_state {
ToolState::PendingCurveInteraction { edge_id, parameter_t, start_mouse } => {
// Drag detected — transition to curve editing
let edge_id = *edge_id;
let parameter_t = *parameter_t;
let start_mouse = *start_mouse;
self.start_curve_editing(edge_id, parameter_t, start_mouse, active_layer_id, shared);
self.update_vector_editing(point, shared);
}
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } => {
// Vector editing - update happens in helper method
self.update_vector_editing(point, shared);
@ -2277,11 +2421,28 @@ impl StagePane {
// Mouse up: finish interaction
let drag_stopped = response.drag_stopped();
let pointer_released = ui.input(|i| i.pointer.any_released());
let is_pending_curve = matches!(shared.tool_state, ToolState::PendingCurveInteraction { .. });
let is_drag_or_marquee = matches!(shared.tool_state, ToolState::DraggingSelection { .. } | ToolState::MarqueeSelecting { .. });
let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. });
if drag_stopped || (pointer_released && (is_drag_or_marquee || is_vector_editing)) {
if drag_stopped || (pointer_released && (is_drag_or_marquee || is_vector_editing || is_pending_curve)) {
match shared.tool_state.clone() {
ToolState::PendingCurveInteraction { edge_id, .. } => {
// Mouse released without drag — select the edge
let shift_held = ui.input(|i| i.modifiers.shift);
let document = shared.action_executor.document();
if let Some(layer) = document.get_layer(&active_layer_id) {
if let AnyLayer::Vector(vl) = layer {
if let Some(dcel) = vl.dcel_at_time(*shared.playback_time) {
if !shift_held {
shared.selection.clear_dcel_selection();
}
shared.selection.select_edge(edge_id, dcel);
}
}
}
*shared.tool_state = ToolState::Idle;
}
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
// Finish vector editing - create action
self.finish_vector_editing(active_layer_id, shared);
@ -2305,8 +2466,7 @@ impl StagePane {
_ => return,
};
// Separate shape instances from clip instances
let mut shape_instance_positions = HashMap::new();
// Process clip instance drags
let mut clip_instance_transforms = HashMap::new();
for (id, original_pos) in original_positions {
@ -2315,12 +2475,7 @@ impl StagePane {
original_pos.y + delta.y,
);
// Check if this is a shape instance or clip instance
if shared.selection.contains_shape_instance(&id) {
shape_instance_positions.insert(id, (original_pos, new_pos));
} else if shared.selection.contains_clip_instance(&id) {
// For clip instances, we need to get the full Transform
// Find the clip instance in the layer
if shared.selection.contains_clip_instance(&id) {
if let Some(clip_inst) = vector_layer.clip_instances.iter()
.find(|ci| ci.id == id) {
let mut old_transform = clip_inst.transform.clone();
@ -2336,13 +2491,6 @@ impl StagePane {
}
}
// Create and submit move action for shape instances
if !shape_instance_positions.is_empty() {
use lightningbeam_core::actions::MoveShapeInstancesAction;
let action = MoveShapeInstancesAction::new(active_layer_id, *shared.playback_time, shape_instance_positions);
shared.pending_actions.push(Box::new(action));
}
// Create and submit transform action for clip instances
if !clip_instance_transforms.is_empty() {
use lightningbeam_core::actions::TransformClipInstancesAction;
@ -2383,8 +2531,8 @@ impl StagePane {
*shared.playback_time,
);
// Hit test shape instances in rectangle
let shape_hits = hit_test::hit_test_objects_in_rect(
// Hit test DCEL elements in rectangle
let dcel_hits = hit_test::hit_test_dcel_in_rect(
vector_layer,
*shared.playback_time,
selection_rect,
@ -2393,31 +2541,16 @@ impl StagePane {
// Add clip instances to selection
for clip_id in clip_hits {
if shift_held {
shared.selection.add_clip_instance(clip_id);
} else {
// First hit replaces selection
if shared.selection.is_empty() {
shared.selection.add_clip_instance(clip_id);
} else {
// Subsequent hits add to selection
shared.selection.add_clip_instance(clip_id);
}
}
shared.selection.add_clip_instance(clip_id);
}
// Add shape instances to selection
for obj_id in shape_hits {
if shift_held {
shared.selection.add_shape_instance(obj_id);
} else {
// First hit replaces selection
if shared.selection.is_empty() {
shared.selection.add_shape_instance(obj_id);
} else {
// Subsequent hits add to selection
shared.selection.add_shape_instance(obj_id);
}
// Add DCEL elements to selection
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
for edge_id in dcel_hits.edges {
shared.selection.select_edge(edge_id, dcel);
}
for face_id in dcel_hits.faces {
shared.selection.select_face(face_id, dcel);
}
}
@ -2605,7 +2738,24 @@ impl StagePane {
}
};
// Get current DCEL state (after edits) as dcel_after
// If we were editing a curve, recompute intersections before snapshotting.
// This detects new crossings between the edited edge and other edges,
// splitting them to maintain valid DCEL topology.
let editing_edge_id = match &*shared.tool_state {
lightningbeam_core::tool::ToolState::EditingCurve { edge_id, .. } => Some(*edge_id),
_ => None,
};
if let Some(edge_id) = editing_edge_id {
let document = shared.action_executor.document_mut();
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) {
if let Some(dcel) = vl.dcel_at_time_mut(cache.time) {
dcel.recompute_edge_intersections(edge_id);
}
}
}
// Get current DCEL state (after edits + intersection splits) as dcel_after
let dcel_after = {
let document = shared.action_executor.document();
match document.get_layer(&active_layer_id) {
@ -3348,10 +3498,7 @@ impl StagePane {
shared.selection.clear();
// Select fully-inside shapes directly
for &id in &classification.fully_inside {
shared.selection.add_shape_instance(id);
}
// TODO: DCEL - region selection element selection deferred to Phase 2
// For intersecting shapes: compute clip and create temporary splits
let splits = Vec::new();
@ -4154,7 +4301,7 @@ impl StagePane {
}
// For vector layers: single object uses rotated bbox, multiple objects use axis-aligned bbox
let total_selected = shared.selection.shape_instances().len() + shared.selection.clip_instances().len();
let total_selected = shared.selection.clip_instances().len();
if total_selected == 1 {
// Single object - rotated bounding box
self.handle_transform_single_object(ui, response, point, &active_layer_id, shared);
@ -4368,9 +4515,7 @@ impl StagePane {
use vello::kurbo::Affine;
// Get the single selected object (either shape instance or clip instance)
let object_id = if let Some(&id) = shared.selection.shape_instances().iter().next() {
id
} else if let Some(&id) = shared.selection.clip_instances().iter().next() {
let object_id = if let Some(&id) = shared.selection.clip_instances().iter().next() {
id
} else {
return; // No selection, shouldn't happen
@ -5170,19 +5315,16 @@ impl StagePane {
if let Some(active_layer_id) = shared.active_layer_id {
use std::collections::HashMap;
let mut shape_instance_positions = HashMap::new();
let mut clip_instance_transforms = HashMap::new();
// Separate shape instances from clip instances
// Process clip instances from drag
for (object_id, original_pos) in original_positions {
let new_pos = Point::new(
original_pos.x + delta.x,
original_pos.y + delta.y,
);
if shared.selection.contains_shape_instance(&object_id) {
shape_instance_positions.insert(object_id, (original_pos, new_pos));
} else if shared.selection.contains_clip_instance(&object_id) {
if shared.selection.contains_clip_instance(&object_id) {
// For clip instances, get the full transform
if let Some(layer) = shared.action_executor.document().get_layer(active_layer_id) {
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
@ -5202,13 +5344,6 @@ impl StagePane {
}
}
// Create action for shape instances
if !shape_instance_positions.is_empty() {
use lightningbeam_core::actions::MoveShapeInstancesAction;
let action = MoveShapeInstancesAction::new(*active_layer_id, *shared.playback_time, shape_instance_positions);
shared.pending_actions.push(Box::new(action));
}
// Create action for clip instances
if !clip_instance_transforms.is_empty() {
use lightningbeam_core::actions::TransformClipInstancesAction;
@ -5247,8 +5382,8 @@ impl StagePane {
*shared.playback_time,
);
// Hit test shape instances in rectangle
let shape_hits = hit_test::hit_test_objects_in_rect(
// Hit test DCEL elements in rectangle
let dcel_hits = hit_test::hit_test_dcel_in_rect(
vector_layer,
*shared.playback_time,
selection_rect,
@ -5260,9 +5395,14 @@ impl StagePane {
shared.selection.add_clip_instance(clip_id);
}
// Add shape instances to selection
for obj_id in shape_hits {
shared.selection.add_shape_instance(obj_id);
// Add DCEL elements to selection
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
for edge_id in dcel_hits.edges {
shared.selection.select_edge(edge_id, dcel);
}
for face_id in dcel_hits.faces {
shared.selection.select_face(face_id, dcel);
}
}
}
}
@ -5473,20 +5613,26 @@ impl StagePane {
let cp_color = egui::Color32::from_rgba_premultiplied(180, 180, 255, 200);
let cp_hover_color = egui::Color32::from_rgb(100, 160, 255);
let cp_line_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgba_premultiplied(120, 120, 200, 150));
let curve_hover_stroke = egui::Stroke::new(3.0 / self.zoom, egui::Color32::from_rgb(60, 140, 255));
// Determine what's hovered
let hover_vertex = match hit {
Some(VectorEditHit::Vertex { vertex_id }) => Some(vertex_id),
_ => None,
// Determine what's hovered (suppress during active editing to avoid flicker)
let is_editing = matches!(
*shared.tool_state,
lightningbeam_core::tool::ToolState::EditingCurve { .. }
| lightningbeam_core::tool::ToolState::EditingVertex { .. }
| lightningbeam_core::tool::ToolState::EditingControlPoint { .. }
| lightningbeam_core::tool::ToolState::PendingCurveInteraction { .. }
);
let hover_vertex = if is_editing { None } else {
match hit {
Some(VectorEditHit::Vertex { vertex_id }) => Some(vertex_id),
_ => None,
}
};
let hover_edge = match hit {
Some(VectorEditHit::Curve { edge_id, .. }) => Some(edge_id),
_ => None,
};
let hover_cp = match hit {
Some(VectorEditHit::ControlPoint { edge_id, point_index }) => Some((edge_id, point_index)),
_ => None,
let hover_cp = if is_editing { None } else {
match hit {
Some(VectorEditHit::ControlPoint { edge_id, point_index }) => Some((edge_id, point_index)),
_ => None,
}
};
if is_bezier_edit_mode {
@ -5544,23 +5690,7 @@ impl StagePane {
painter.circle(screen_pos, vertex_hover_radius, vertex_color, vertex_hover_stroke);
}
if let Some(eid) = hover_edge {
// Highlight the hovered curve by drawing it thicker
let curve = &dcel.edge(eid).curve;
// Sample points along the curve for drawing
let segments = 20;
let points: Vec<egui::Pos2> = (0..=segments)
.map(|i| {
let t = i as f64 / segments as f64;
use vello::kurbo::ParamCurve;
let p = curve.eval(t);
world_to_screen(p)
})
.collect();
for pair in points.windows(2) {
painter.line_segment([pair[0], pair[1]], curve_hover_stroke);
}
}
// Note: curve hover highlight is now rendered via Vello stipple in the scene
if let Some((eid, pidx)) = hover_cp {
let curve = &dcel.edge(eid).curve;
@ -5911,6 +6041,16 @@ impl PaneRenderer for StagePane {
None
};
// Compute mouse world position for hover hit testing in the Vello callback
let mouse_world_pos = ui.input(|i| i.pointer.hover_pos())
.filter(|pos| rect.contains(*pos))
.map(|pos| {
let canvas_pos = pos - rect.min;
let doc_pos = (canvas_pos - self.pan_offset) / self.zoom;
let local = self.doc_to_clip_local(doc_pos, shared);
vello::kurbo::Point::new(local.x as f64, local.y as f64)
});
// Use egui's custom painting callback for Vello
// document_arc() returns Arc<Document> - cheap pointer copy, not deep clone
let callback = VelloCallback { ctx: VelloRenderContext {
@ -5936,6 +6076,7 @@ impl PaneRenderer for StagePane {
editing_instance_id: shared.editing_instance_id,
editing_parent_layer_id: shared.editing_parent_layer_id,
region_selection: shared.region_selection.clone(),
mouse_world_pos,
}};
let cb = egui_wgpu::Callback::new_paint_callback(