From bcf62773293608192cdce63bfb6ebee91460c657 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 24 Feb 2026 02:04:07 -0500 Subject: [PATCH] Rebuild DCEL after vector edits --- .../src/curve_intersections.rs | 11 +- .../lightningbeam-core/src/dcel.rs | 657 +++++++++++++++++- .../lightningbeam-core/src/hit_test.rs | 188 ++++- .../lightningbeam-core/src/selection.rs | 457 ++++++------ .../lightningbeam-core/src/tool.rs | 7 + .../lightningbeam-editor/src/main.rs | 155 ++--- .../src/panes/infopanel.rs | 438 +++--------- .../lightningbeam-editor/src/panes/stage.rs | 405 +++++++---- 8 files changed, 1459 insertions(+), 859 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs b/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs index 7d97680..eddc709 100644 --- a/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs +++ b/lightningbeam-ui/lightningbeam-core/src/curve_intersections.rs @@ -259,7 +259,16 @@ fn dedup_intersections(intersections: &mut Vec, 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; diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel.rs b/lightningbeam-ui/lightningbeam-core/src/dcel.rs index eedd362..e8bb388 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel.rs @@ -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> = + 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 = 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)> = 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 = 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, + face_votes: std::collections::HashMap, + } + let cycle_infos: Vec = cycles + .into_iter() + .map(|(start_he, hes)| { + let mut face_votes: std::collections::HashMap = + 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 = + 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> = + vec![None; cycle_infos.len()]; + + for &old_face_raw in &all_old_faces { + let mut best_idx: Option = 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 = + 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); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 61d8d23..4902e53 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -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 { - // TODO: Implement DCEL-based hit testing (faces, edges, vertices) + layer: &VectorLayer, + time: f64, + point: Point, + tolerance: f64, + parent_transform: Affine, +) -> Option { + 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, + pub faces: Vec, +} + +/// 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 { - // 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 } diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index cb0146f..cfdf76b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -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, + /// Currently selected vertices + selected_vertices: HashSet, - /// Currently selected shapes (definitions) - selected_shapes: Vec, + /// Currently selected edges + selected_edges: HashSet, + + /// Currently selected faces + selected_faces: HashSet, /// Currently selected clip instances selected_clip_instances: Vec, @@ -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 { + &self.selected_edges + } + + /// Get selected faces. + pub fn selected_faces(&self) -> &HashSet { + &self.selected_faces + } + + /// Get selected vertices. + pub fn selected_vertices(&self) -> &HashSet { + &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, - /// 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, - /// 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()); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index acf4a35..e59485a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -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, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index e97a674..00e74e7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -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 = self.selection.shapes().to_vec(); + // Delete selected edges via snapshot-based ModifyDcelAction + let edge_ids: Vec = + 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 = 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 = self.selection.shape_instances().to_vec(); - let clip_ids: Vec = 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 = 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 = self.selection.shape_instances().to_vec(); - let clip_ids: Vec = 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 = 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); + } } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index d0bccce..2ea6905 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -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, - /// Selected shape instance IDs - instance_ids: Vec, - /// Shape IDs referenced by selected instances - shape_ids: Vec, - - // Transform values (None = mixed values across selection) - x: Option, - y: Option, - rotation: Option, - scale_x: Option, - scale_y: Option, - skew_x: Option, - skew_y: Option, - opacity: Option, // Shape property values (None = mixed) fill_color: Option>, @@ -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> = None; + let mut first_stroke_width: Option = 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> = 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( - &self, - ui: &mut Ui, - label: &str, - value: Option, - speed: f64, - range: std::ops::RangeInclusive, - 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" } )); } }); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index daee337..7210b72 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -386,6 +386,8 @@ struct VelloRenderContext { editing_parent_layer_id: Option, /// Active region selection state (for rendering boundary overlay) region_selection: Option, + /// Mouse position in document-local (clip-local) world coordinates, for hover hit testing + mouse_world_pos: Option, } /// 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>>> = 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 = 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 = 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 = (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 - 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(