Add snapping for vector editing

This commit is contained in:
Skyler Lehmkuhl 2026-02-25 03:29:42 -05:00
parent 2b63fdd2c5
commit 4c34c8a17d
8 changed files with 514 additions and 77 deletions

View File

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

View File

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

View File

@ -45,3 +45,4 @@ pub mod export;
pub mod clipboard;
pub mod region_select;
pub mod dcel;
pub mod snap;

View File

@ -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<VertexId>,
/// Edges to skip (e.g. edges connected to the dragged vertex).
pub edges: Vec<EdgeId>,
}
/// 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<SnapResult> {
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());
}
}

View File

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

View File

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

View File

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

View File

@ -2072,6 +2072,8 @@ pub struct StagePane {
last_viewport_rect: Option<egui::Rect>,
// Vector editing cache
dcel_editing_cache: Option<DcelEditingCache>,
// Current snap result (for visual feedback rendering)
current_snap: Option<lightningbeam_core::snap::SnapResult>,
}
/// 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()) {