Add snapping for vector editing
This commit is contained in:
parent
2b63fdd2c5
commit
4c34c8a17d
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,3 +45,4 @@ pub mod export;
|
|||
pub mod clipboard;
|
||||
pub mod region_select;
|
||||
pub mod dcel;
|
||||
pub mod snap;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue