From 4c34c8a17dc0fb2d19ab975847df1dfd90112f43 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 25 Feb 2026 03:29:42 -0500 Subject: [PATCH] Add snapping for vector editing --- lightningbeam-ui/Cargo.lock | 56 +--- .../lightningbeam-core/src/dcel.rs | 17 +- .../lightningbeam-core/src/lib.rs | 1 + .../lightningbeam-core/src/snap.rs | 269 ++++++++++++++++++ .../lightningbeam-editor/src/main.rs | 6 + .../src/panes/infopanel.rs | 14 +- .../lightningbeam-editor/src/panes/mod.rs | 2 + .../lightningbeam-editor/src/panes/stage.rs | 226 +++++++++++++-- 8 files changed, 514 insertions(+), 77 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/snap.rs diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 9ff5ec5..b1a7662 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -1817,8 +1817,6 @@ dependencies = [ [[package]] name = "ecolor" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", @@ -1828,8 +1826,6 @@ dependencies = [ [[package]] name = "eframe" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" dependencies = [ "ahash 0.8.12", "bytemuck", @@ -1865,8 +1861,6 @@ dependencies = [ [[package]] name = "egui" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" dependencies = [ "accesskit", "ahash 0.8.12", @@ -1885,8 +1879,6 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" dependencies = [ "ahash 0.8.12", "bytemuck", @@ -1905,8 +1897,6 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" dependencies = [ "accesskit_winit", "arboard", @@ -1936,8 +1926,6 @@ dependencies = [ [[package]] name = "egui_extras" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed" dependencies = [ "ahash 0.8.12", "egui", @@ -1953,8 +1941,6 @@ dependencies = [ [[package]] name = "egui_glow" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" dependencies = [ "bytemuck", "egui", @@ -1987,8 +1973,6 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "emath" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", "serde", @@ -2064,8 +2048,6 @@ dependencies = [ [[package]] name = "epaint" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" dependencies = [ "ab_glyph", "ahash 0.8.12", @@ -2083,8 +2065,6 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" [[package]] name = "equator" @@ -4050,7 +4030,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.110", @@ -7144,7 +7124,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -8142,35 +8122,3 @@ dependencies = [ "syn 2.0.110", "winnow 0.7.13", ] - -[[patch.unused]] -name = "ecolor" -version = "0.33.2" - -[[patch.unused]] -name = "eframe" -version = "0.33.2" - -[[patch.unused]] -name = "egui" -version = "0.33.2" - -[[patch.unused]] -name = "egui-wgpu" -version = "0.33.2" - -[[patch.unused]] -name = "egui-winit" -version = "0.33.2" - -[[patch.unused]] -name = "egui_extras" -version = "0.33.2" - -[[patch.unused]] -name = "emath" -version = "0.33.2" - -[[patch.unused]] -name = "epaint" -version = "0.33.2" diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel.rs b/lightningbeam-ui/lightningbeam-core/src/dcel.rs index e19e830..4b0a430 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel.rs @@ -2557,13 +2557,28 @@ impl Dcel { v_keep: VertexId, v_remove: VertexId, ) { - // Re-home half-edges from v_remove → v_keep + let keep_pos = self.vertices[v_keep.idx()].position; + + // Re-home half-edges from v_remove → v_keep, and fix curve endpoints for i in 0..self.half_edges.len() { if self.half_edges[i].deleted { continue; } if self.half_edges[i].origin == v_remove { self.half_edges[i].origin = v_keep; + + // Fix the curve endpoint so it matches v_keep's position. + // A half-edge's origin is the start of that half-edge. + // The forward half-edge (index 0) of an edge starts at p0. + // The backward half-edge (index 1) starts at p3. + let edge_id = self.half_edges[i].edge; + let edge = &self.edges[edge_id.idx()]; + let he_id = HalfEdgeId(i as u32); + if edge.half_edges[0] == he_id { + self.edges[edge_id.idx()].curve.p0 = keep_pos; + } else if edge.half_edges[1] == he_id { + self.edges[edge_id.idx()].curve.p3 = keep_pos; + } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 05205a0..70b8848 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -45,3 +45,4 @@ pub mod export; pub mod clipboard; pub mod region_select; pub mod dcel; +pub mod snap; diff --git a/lightningbeam-ui/lightningbeam-core/src/snap.rs b/lightningbeam-ui/lightningbeam-core/src/snap.rs new file mode 100644 index 0000000..36c6cc8 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/snap.rs @@ -0,0 +1,269 @@ +//! Geometry snapping for vector editing +//! +//! Provides snap-to-geometry queries that find the nearest vertex, edge midpoint, +//! or curve point within a given radius. Priority order: Vertex > Midpoint > Curve. + +use crate::dcel::{Dcel, EdgeId, VertexId}; +use vello::kurbo::{ParamCurve, ParamCurveNearest, Point}; + +/// Default snap radius in screen pixels (converted to document space via zoom). +pub const SNAP_SCREEN_RADIUS: f64 = 12.0; + +/// What the cursor snapped to. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SnapTarget { + /// Snapped to an existing vertex position. + Vertex { vertex_id: VertexId }, + /// Snapped to the midpoint (t=0.5) of an edge. + Midpoint { edge_id: EdgeId }, + /// Snapped to the nearest point on a curve. + Curve { edge_id: EdgeId, parameter_t: f64 }, +} + +/// Result of a snap query. +#[derive(Debug, Clone, Copy)] +pub struct SnapResult { + /// The position to snap to (in document/local space). + pub position: Point, + /// What type of element was snapped to. + pub target: SnapTarget, + /// Distance from the query point to the snap position. + pub distance: f64, +} + +/// Configuration for snap behavior. +#[derive(Debug, Clone, Copy)] +pub struct SnapConfig { + /// Snap search radius in document units. + pub radius: f64, + /// Whether vertex snapping is enabled. + pub snap_to_vertices: bool, + /// Whether midpoint snapping is enabled. + pub snap_to_midpoints: bool, + /// Whether curve snapping is enabled. + pub snap_to_curves: bool, +} + +impl SnapConfig { + /// Create a snap config from a screen-pixel radius, converted to document space. + pub fn from_screen_radius(screen_pixels: f64, zoom: f64) -> Self { + Self { + radius: screen_pixels / zoom, + snap_to_vertices: true, + snap_to_midpoints: true, + snap_to_curves: true, + } + } +} + +/// Elements to exclude from snap queries (self-exclusion during drag). +#[derive(Debug, Clone, Default)] +pub struct SnapExclusion { + /// Vertices to skip (e.g. the vertex being dragged). + pub vertices: Vec, + /// Edges to skip (e.g. edges connected to the dragged vertex). + pub edges: Vec, +} + +/// Find the best snap target for a point within a DCEL. +/// +/// Priority: Vertex > Edge Midpoint > Nearest point on Curve. +/// Returns `None` if nothing is within the configured radius. +pub fn find_snap_target( + dcel: &Dcel, + point: Point, + config: &SnapConfig, + exclusion: &SnapExclusion, +) -> Option { + let radius_sq = config.radius * config.radius; + + // Phase 1: Vertex snap (highest priority) + if config.snap_to_vertices { + let mut best: Option<(VertexId, Point, f64)> = None; + for (i, vertex) in dcel.vertices.iter().enumerate() { + if vertex.deleted { + continue; + } + let vid = VertexId(i as u32); + if exclusion.vertices.contains(&vid) { + continue; + } + let dx = vertex.position.x - point.x; + let dy = vertex.position.y - point.y; + let dist_sq = dx * dx + dy * dy; + if dist_sq <= radius_sq { + if best.is_none() || dist_sq < best.unwrap().2 { + best = Some((vid, vertex.position, dist_sq)); + } + } + } + if let Some((vid, pos, dist_sq)) = best { + return Some(SnapResult { + position: pos, + target: SnapTarget::Vertex { vertex_id: vid }, + distance: dist_sq.sqrt(), + }); + } + } + + // Phase 2: Edge midpoint snap + if config.snap_to_midpoints { + let mut best: Option<(EdgeId, Point, f64)> = None; + for (i, edge) in dcel.edges.iter().enumerate() { + if edge.deleted { + continue; + } + let eid = EdgeId(i as u32); + if exclusion.edges.contains(&eid) { + continue; + } + let midpoint = edge.curve.eval(0.5); + let dx = midpoint.x - point.x; + let dy = midpoint.y - point.y; + let dist_sq = dx * dx + dy * dy; + if dist_sq <= radius_sq { + if best.is_none() || dist_sq < best.unwrap().2 { + best = Some((eid, midpoint, dist_sq)); + } + } + } + if let Some((eid, pos, dist_sq)) = best { + return Some(SnapResult { + position: pos, + target: SnapTarget::Midpoint { edge_id: eid }, + distance: dist_sq.sqrt(), + }); + } + } + + // Phase 3: Nearest point on curve + if config.snap_to_curves { + let mut best: Option<(EdgeId, f64, Point, f64)> = None; + for (i, edge) in dcel.edges.iter().enumerate() { + if edge.deleted { + continue; + } + let eid = EdgeId(i as u32); + if exclusion.edges.contains(&eid) { + continue; + } + let nearest = edge.curve.nearest(point, 0.5); + let dist = nearest.distance_sq.sqrt(); + if dist <= config.radius { + if best.is_none() || dist < best.unwrap().3 { + let snap_point = edge.curve.eval(nearest.t); + best = Some((eid, nearest.t, snap_point, dist)); + } + } + } + if let Some((eid, t, pos, dist)) = best { + return Some(SnapResult { + position: pos, + target: SnapTarget::Curve { + edge_id: eid, + parameter_t: t, + }, + distance: dist, + }); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use vello::kurbo::CubicBez; + + fn make_dcel_with_edge() -> Dcel { + let mut dcel = Dcel::new(); + let curve = CubicBez::new( + Point::new(0.0, 0.0), + Point::new(33.0, 0.0), + Point::new(67.0, 0.0), + Point::new(100.0, 0.0), + ); + dcel.insert_stroke(&[curve], None, None, 0.5); + dcel + } + + #[test] + fn snap_to_vertex() { + let dcel = make_dcel_with_edge(); + let config = SnapConfig { + radius: 5.0, + snap_to_vertices: true, + snap_to_midpoints: true, + snap_to_curves: true, + }; + let exclusion = SnapExclusion::default(); + let result = find_snap_target(&dcel, Point::new(2.0, 0.0), &config, &exclusion); + assert!(result.is_some()); + assert!(matches!(result.unwrap().target, SnapTarget::Vertex { .. })); + } + + #[test] + fn snap_to_midpoint() { + let dcel = make_dcel_with_edge(); + let config = SnapConfig { + radius: 5.0, + snap_to_vertices: true, + snap_to_midpoints: true, + snap_to_curves: true, + }; + let exclusion = SnapExclusion::default(); + // Point near midpoint (50, 0) but far from vertices (0,0) and (100,0) + let result = find_snap_target(&dcel, Point::new(51.0, 0.0), &config, &exclusion); + assert!(result.is_some()); + assert!(matches!(result.unwrap().target, SnapTarget::Midpoint { .. })); + } + + #[test] + fn snap_to_curve() { + let dcel = make_dcel_with_edge(); + let config = SnapConfig { + radius: 5.0, + snap_to_vertices: true, + snap_to_midpoints: true, + snap_to_curves: true, + }; + let exclusion = SnapExclusion::default(); + // Point near t=0.25 on curve (25, 0) — not near a vertex or midpoint + let result = find_snap_target(&dcel, Point::new(25.0, 3.0), &config, &exclusion); + assert!(result.is_some()); + assert!(matches!(result.unwrap().target, SnapTarget::Curve { .. })); + } + + #[test] + fn no_snap_outside_radius() { + let dcel = make_dcel_with_edge(); + let config = SnapConfig { + radius: 5.0, + snap_to_vertices: true, + snap_to_midpoints: true, + snap_to_curves: true, + }; + let exclusion = SnapExclusion::default(); + let result = find_snap_target(&dcel, Point::new(50.0, 20.0), &config, &exclusion); + assert!(result.is_none()); + } + + #[test] + fn exclusion_skips_vertex() { + let dcel = make_dcel_with_edge(); + let config = SnapConfig { + radius: 5.0, + snap_to_vertices: true, + snap_to_midpoints: false, + snap_to_curves: false, + }; + // Exclude vertex 0 + let exclusion = SnapExclusion { + vertices: vec![VertexId(0)], + edges: vec![], + }; + let result = find_snap_target(&dcel, Point::new(2.0, 0.0), &config, &exclusion); + assert!(result.is_none()); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index e6ccef6..8f6752b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -743,6 +743,7 @@ struct EditorApp { // Tool-specific options (displayed in infopanel) stroke_width: f64, // Stroke width for drawing tools (default: 3.0) fill_enabled: bool, // Whether to fill shapes (default: true) + snap_enabled: bool, // Whether to snap to geometry (default: true) paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0) polygon_sides: u32, // Number of sides for polygon tool (default: 5) // Region select state @@ -968,6 +969,7 @@ impl EditorApp { last_import_filter: ImportFilter::default(), // Default to "All Supported" stroke_width: 3.0, // Default stroke width fill_enabled: true, // Default to filling shapes + snap_enabled: true, // Default to snapping paint_bucket_gap_tolerance: 5.0, // Default gap tolerance polygon_sides: 5, // Default to pentagon region_selection: None, @@ -4752,6 +4754,7 @@ impl eframe::App for EditorApp { dragging_asset: &mut self.dragging_asset, stroke_width: &mut self.stroke_width, fill_enabled: &mut self.fill_enabled, + snap_enabled: &mut self.snap_enabled, paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, polygon_sides: &mut self.polygon_sides, layer_to_track_map: &self.layer_to_track_map, @@ -5075,6 +5078,7 @@ struct RenderContext<'a> { // Tool-specific options for infopanel stroke_width: &'a mut f64, fill_enabled: &'a mut bool, + snap_enabled: &'a mut bool, paint_bucket_gap_tolerance: &'a mut f64, polygon_sides: &'a mut u32, /// Mapping from Document layer UUIDs to daw-backend TrackIds @@ -5584,6 +5588,7 @@ fn render_pane( dragging_asset: ctx.dragging_asset, stroke_width: ctx.stroke_width, fill_enabled: ctx.fill_enabled, + snap_enabled: ctx.snap_enabled, paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance, polygon_sides: ctx.polygon_sides, midi_event_cache: ctx.midi_event_cache, @@ -5666,6 +5671,7 @@ fn render_pane( dragging_asset: ctx.dragging_asset, stroke_width: ctx.stroke_width, fill_enabled: ctx.fill_enabled, + snap_enabled: ctx.snap_enabled, paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance, polygon_sides: ctx.polygon_sides, midi_event_cache: ctx.midi_event_cache, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index b64d73c..97031db 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -143,9 +143,14 @@ impl InfopanelPane { let tool = *shared.selected_tool; // Only show tool options for tools that have options - let has_options = matches!( + let is_vector_tool = matches!( tool, - Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::PaintBucket | Tool::Polygon | Tool::Line | Tool::RegionSelect + Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle + | Tool::Ellipse | Tool::Line | Tool::Polygon + ); + let has_options = is_vector_tool || matches!( + tool, + Tool::PaintBucket | Tool::RegionSelect ); if !has_options { @@ -159,6 +164,11 @@ impl InfopanelPane { self.tool_section_open = true; ui.add_space(4.0); + if is_vector_tool { + ui.checkbox(shared.snap_enabled, "Snap to Geometry"); + ui.add_space(2.0); + } + match tool { Tool::Draw => { // Stroke width diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 1ab3c81..cb7bb07 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -195,6 +195,8 @@ pub struct SharedPaneState<'a> { pub stroke_width: &'a mut f64, /// Whether to fill shapes when drawing (Rectangle, Ellipse, Polygon) pub fill_enabled: &'a mut bool, + /// Whether to snap to geometry when editing vectors + pub snap_enabled: &'a mut bool, /// Fill gap tolerance for paint bucket tool pub paint_bucket_gap_tolerance: &'a mut f64, /// Number of sides for polygon tool diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 649d1e5..db2c292 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -2072,6 +2072,8 @@ pub struct StagePane { last_viewport_rect: Option, // Vector editing cache dcel_editing_cache: Option, + // Current snap result (for visual feedback rendering) + current_snap: Option, } /// Cached DCEL snapshot for undo when editing vertices, curves, or control points @@ -2133,6 +2135,7 @@ impl StagePane { pending_eyedropper_sample: None, last_viewport_rect: None, dcel_editing_cache: None, + current_snap: None, } } @@ -2725,6 +2728,7 @@ impl StagePane { ) { use lightningbeam_core::bezpath_editing::mold_curve; use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::snap::{self, SnapConfig, SnapExclusion, SNAP_SCREEN_RADIUS}; use lightningbeam_core::tool::ToolState; use vello::kurbo::Vec2; @@ -2737,8 +2741,41 @@ impl StagePane { // Clone tool state to avoid borrow conflict let tool_state = shared.tool_state.clone(); + let snap_enabled = *shared.snap_enabled; - // Get mutable DCEL access + // Phase 1: Compute snap target with immutable DCEL borrow. + // Don't snap during curve molding — the mouse is a relative guide for + // adjusting control points, not an absolute target. + let skip_snap = matches!(tool_state, ToolState::EditingCurve { .. }); + let snap_result = if snap_enabled && !skip_snap { + let document = shared.action_executor.document(); + let dcel = match document.get_layer(&layer_id) { + Some(AnyLayer::Vector(vl)) => vl.dcel_at_time(time), + _ => None, + }; + dcel.and_then(|dcel| { + let config = SnapConfig::from_screen_radius(SNAP_SCREEN_RADIUS, self.zoom as f64); + let exclusion = match &tool_state { + ToolState::EditingVertex { vertex_id, connected_edges } => SnapExclusion { + vertices: vec![*vertex_id], + edges: connected_edges.clone(), + }, + ToolState::EditingControlPoint { edge_id, .. } => SnapExclusion { + edges: vec![*edge_id], + ..Default::default() + }, + _ => SnapExclusion::default(), + }; + snap::find_snap_target(dcel, mouse_pos, &config, &exclusion) + }) + } else { + None + }; + + self.current_snap = snap_result; + let effective_pos = snap_result.map(|r| r.position).unwrap_or(mouse_pos); + + // Phase 2: Mutate DCEL with the (possibly snapped) position let document = shared.action_executor.document_mut(); let dcel = match document.get_layer_mut(&layer_id) { Some(AnyLayer::Vector(vl)) => match vl.dcel_at_time_mut(time) { @@ -2750,10 +2787,9 @@ impl StagePane { match tool_state { ToolState::EditingVertex { vertex_id, connected_edges } => { - // Snap vertex directly to cursor position let old_pos = dcel.vertex(vertex_id).position; - let delta = Vec2::new(mouse_pos.x - old_pos.x, mouse_pos.y - old_pos.y); - dcel.vertex_mut(vertex_id).position = mouse_pos; + let delta = Vec2::new(effective_pos.x - old_pos.x, effective_pos.y - old_pos.y); + dcel.vertex_mut(vertex_id).position = effective_pos; // Update connected edges: shift the adjacent control point by the same delta for &edge_id in &connected_edges { @@ -2763,26 +2799,24 @@ impl StagePane { let mut curve = dcel.edge(edge_id).curve; if fwd_origin == vertex_id { - // This vertex is p0 of the curve - curve.p0 = mouse_pos; + curve.p0 = effective_pos; curve.p1 = curve.p1 + delta; } else { - // This vertex is p3 of the curve - curve.p3 = mouse_pos; + curve.p3 = effective_pos; curve.p2 = curve.p2 + delta; } dcel.edge_mut(edge_id).curve = curve; } } ToolState::EditingCurve { edge_id, original_curve, start_mouse, .. } => { - let molded_curve = mold_curve(&original_curve, &mouse_pos, &start_mouse); + let molded_curve = mold_curve(&original_curve, &effective_pos, &start_mouse); dcel.edge_mut(edge_id).curve = molded_curve; } ToolState::EditingControlPoint { edge_id, point_index, .. } => { let curve = &mut dcel.edge_mut(edge_id).curve; match point_index { - 1 => curve.p1 = mouse_pos, - 2 => curve.p2 = mouse_pos, + 1 => curve.p1 = effective_pos, + 2 => curve.p2 = effective_pos, _ => {} } } @@ -2857,8 +2891,9 @@ impl StagePane { // the action in the undo stack with dcel_before for rollback) let _ = shared.action_executor.execute(Box::new(action)); - // Reset tool state + // Reset tool state and clear snap indicator *shared.tool_state = lightningbeam_core::tool::ToolState::Idle; + self.current_snap = None; } /// Handle BezierEdit tool - similar to Select but with control point editing @@ -3001,6 +3036,42 @@ impl StagePane { }; } + /// Compute snap for shape/draw tools (no exclusions). + /// Derives active layer and time from `shared`. Updates `self.current_snap` + /// and returns the (possibly snapped) position. + fn snap_point( + &mut self, + point: vello::kurbo::Point, + shared: &SharedPaneState, + ) -> vello::kurbo::Point { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::snap::{self, SnapConfig, SnapExclusion, SNAP_SCREEN_RADIUS}; + + if !*shared.snap_enabled { + self.current_snap = None; + return point; + } + + let layer_id = match *shared.active_layer_id { + Some(id) => id, + None => { self.current_snap = None; return point; } + }; + let time = *shared.playback_time; + + let dcel = match shared.action_executor.document().get_layer(&layer_id) { + Some(AnyLayer::Vector(vl)) => vl.dcel_at_time(time), + _ => None, + }; + + let result = dcel.and_then(|dcel| { + let config = SnapConfig::from_screen_radius(SNAP_SCREEN_RADIUS, self.zoom as f64); + snap::find_snap_target(dcel, point, &config, &SnapExclusion::default()) + }); + + self.current_snap = result; + result.map(|r| r.position).unwrap_or(point) + } + fn handle_rectangle_tool( &mut self, ui: &mut egui::Ui, @@ -3030,7 +3101,7 @@ impl StagePane { return; } - let point = Point::new(world_pos.x as f64, world_pos.y as f64); + let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); // Mouse down: start creating rectangle (clears any previous preview) if response.drag_started() || response.clicked() { @@ -3163,7 +3234,7 @@ impl StagePane { return; } - let point = Point::new(world_pos.x as f64, world_pos.y as f64); + let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); // Mouse down: start creating ellipse (clears any previous preview) if response.drag_started() || response.clicked() { @@ -3287,7 +3358,7 @@ impl StagePane { return; } - let point = Point::new(world_pos.x as f64, world_pos.y as f64); + let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); // Mouse down: start creating line if response.drag_started() || response.clicked() { @@ -3369,7 +3440,7 @@ impl StagePane { return; } - let point = Point::new(world_pos.x as f64, world_pos.y as f64); + let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared); // Mouse down: start creating polygon (center point) if response.drag_started() || response.clicked() { @@ -3745,16 +3816,18 @@ impl StagePane { let point = Point::new(world_pos.x as f64, world_pos.y as f64); - // Mouse down: start drawing path + // Mouse down: start drawing path (snap the first point) if response.drag_started() || response.clicked() { + let snapped_start = self.snap_point(point, shared); *shared.tool_state = ToolState::DrawingPath { - points: vec![point], + points: vec![snapped_start], simplify_mode: *shared.draw_simplify_mode, }; } - // Mouse drag: add points to path + // Mouse drag: add points to path (no snapping for intermediate freehand points) if response.dragged() { + self.current_snap = None; if let ToolState::DrawingPath { points, simplify_mode: _ } = &mut *shared.tool_state { // Only add point if it's far enough from the last point (reduce noise) const MIN_POINT_DISTANCE: f64 = 2.0; @@ -3770,8 +3843,21 @@ impl StagePane { } } - // Mouse up: complete the path and create shape + // Mouse up: snap the last point, then complete the path and create shape if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::DrawingPath { .. })) { + // Snap the final point (extract last point first to avoid borrow conflict) + let last_point = if let ToolState::DrawingPath { points, .. } = &*shared.tool_state { + if points.len() >= 2 { Some(*points.last().unwrap()) } else { None } + } else { + None + }; + if let Some(last) = last_point { + let snapped_end = self.snap_point(last, shared); + if let ToolState::DrawingPath { points, .. } = &mut *shared.tool_state { + *points.last_mut().unwrap() = snapped_end; + } + } + self.current_snap = None; if let ToolState::DrawingPath { points, simplify_mode } = shared.tool_state.clone() { // Only create shape if we have enough points if points.len() >= 2 { @@ -6061,6 +6147,103 @@ impl StagePane { } } } + + /// Render snap indicator when snap is active (works for all vector-editing tools). + /// Also computes hover snap when idle (no active drag snap) so the user can + /// preview snap targets before clicking. + fn render_snap_indicator( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + shared: &SharedPaneState, + ) { + use lightningbeam_core::snap::SnapTarget; + use lightningbeam_core::tool::Tool; + + if !*shared.snap_enabled { + return; + } + + let is_vector_tool = matches!( + *shared.selected_tool, + Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle + | Tool::Ellipse | Tool::Line | Tool::Polygon + ); + + // Recompute hover snap every frame when idle (not actively editing/drawing) + let is_idle = matches!(*shared.tool_state, lightningbeam_core::tool::ToolState::Idle); + if is_vector_tool && is_idle { + if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) { + if rect.contains(pos) { + let canvas_pos = pos - rect.min; + let doc_pos = egui::vec2( + (canvas_pos.x - self.pan_offset.x) / self.zoom, + (canvas_pos.y - self.pan_offset.y) / self.zoom, + ); + let local = self.doc_to_clip_local(doc_pos, shared); + let point = vello::kurbo::Point::new(local.x as f64, local.y as f64); + self.snap_point(point, shared); + } else { + self.current_snap = None; + } + } else { + self.current_snap = None; + } + } + + let snap_result = match &self.current_snap { + Some(r) => r, + None => return, + }; + + let world_to_screen = |world_pos: vello::kurbo::Point| -> egui::Pos2 { + let doc_pos = self.clip_local_to_doc(world_pos, shared); + let screen_x = (doc_pos.x as f32 * self.zoom) + self.pan_offset.x + rect.min.x; + let screen_y = (doc_pos.y as f32 * self.zoom) + self.pan_offset.y + rect.min.y; + egui::pos2(screen_x, screen_y) + }; + + let painter = ui.painter_at(rect); + let screen_pos = world_to_screen(snap_result.position); + + // Reuse existing vertex visual constants + let vertex_hover_radius = 6.0_f32; + let vertex_color = egui::Color32::WHITE; + let vertex_hover_stroke = egui::Stroke::new(2.0, egui::Color32::from_rgb(60, 140, 255)); + + match snap_result.target { + SnapTarget::Vertex { .. } => { + // Same circle as the existing vertex hover indicator + painter.circle(screen_pos, vertex_hover_radius, vertex_color, vertex_hover_stroke); + } + SnapTarget::Midpoint { .. } => { + // Square indicator, same style as vertex but square + let s = vertex_hover_radius; + painter.rect( + egui::Rect::from_center_size(screen_pos, egui::vec2(s * 2.0, s * 2.0)), + 0.0, + vertex_color, + vertex_hover_stroke, + egui::StrokeKind::Middle, + ); + } + SnapTarget::Curve { edge_id, .. } => { + // Stipple highlight on the snapped edge (matching existing curve hover) + use lightningbeam_core::layer::AnyLayer; + if let Some(layer_id) = *shared.active_layer_id { + if let Some(AnyLayer::Vector(vl)) = shared.action_executor.document().get_layer(&layer_id) { + if let Some(dcel) = vl.dcel_at_time(*shared.playback_time) { + let edge = dcel.edge(edge_id); + if !edge.deleted { + // Draw a small circle at the snap point on the curve + painter.circle(screen_pos, 4.0, egui::Color32::TRANSPARENT, vertex_hover_stroke); + } + } + } + } + } + } + } } @@ -6517,6 +6700,9 @@ impl PaneRenderer for StagePane { // Render vector editing overlays (vertices, control points, etc.) self.render_vector_editing_overlays(ui, rect, shared); + // Render snap indicator (works for all tools, not just Select/BezierEdit) + self.render_snap_indicator(ui, rect, shared); + // Set custom tool cursor when pointer is over the stage canvas // (system cursors from transform handles take priority via render_overlay check) if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) {