Lightningbeam/lightningbeam-ui/lightningbeam-core/src/snap.rs

270 lines
8.7 KiB
Rust

//! 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::vector_graph::{VectorGraph, 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(
graph: &VectorGraph,
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 graph.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 graph.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 graph.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_graph_with_edge() -> VectorGraph {
let mut graph = VectorGraph::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),
);
graph.insert_stroke(&[curve], None, None, 0.5);
graph
}
#[test]
fn snap_to_vertex() {
let graph = make_graph_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(&graph, 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 graph = make_graph_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(&graph, 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 graph = make_graph_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(&graph, 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 graph = make_graph_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(&graph, Point::new(50.0, 20.0), &config, &exclusion);
assert!(result.is_none());
}
#[test]
fn exclusion_skips_vertex() {
let graph = make_graph_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(&graph, Point::new(2.0, 0.0), &config, &exclusion);
assert!(result.is_none());
}
}