use kurbo::Point;
pub const GRID_SIZE: f64 = 20.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SnapMode {
#[default]
None,
Grid,
Shapes,
All,
}
impl SnapMode {
pub fn next(self) -> Self {
match self {
SnapMode::None => SnapMode::Grid,
SnapMode::Grid => SnapMode::Shapes,
SnapMode::Shapes => SnapMode::All,
SnapMode::All => SnapMode::None,
}
}
pub fn snaps_to_grid(self) -> bool {
matches!(self, SnapMode::Grid | SnapMode::All)
}
pub fn snaps_to_shapes(self) -> bool {
matches!(self, SnapMode::Shapes | SnapMode::All)
}
pub fn is_enabled(self) -> bool {
self != SnapMode::None
}
}
#[derive(Debug, Clone, Copy)]
pub struct SnapResult {
pub point: Point,
pub snapped_x: bool,
pub snapped_y: bool,
}
impl SnapResult {
pub fn none(point: Point) -> Self {
Self {
point,
snapped_x: false,
snapped_y: false,
}
}
pub fn is_snapped(&self) -> bool {
self.snapped_x || self.snapped_y
}
}
pub const ANGLE_SNAP_INCREMENT: f64 = 15.0;
#[derive(Debug, Clone, Copy)]
pub struct AngleSnapResult {
pub point: Point,
pub angle_degrees: f64,
pub original_angle_degrees: f64,
pub snapped: bool,
pub distance: f64,
}
impl AngleSnapResult {
pub fn none(point: Point, start: Point) -> Self {
let dx = point.x - start.x;
let dy = point.y - start.y;
let angle = dy.atan2(dx).to_degrees();
let angle_normalized = if angle < 0.0 { angle + 360.0 } else { angle };
let distance = (dx * dx + dy * dy).sqrt();
Self {
point,
angle_degrees: angle_normalized,
original_angle_degrees: angle_normalized,
snapped: false,
distance,
}
}
}
pub fn snap_angle(angle_degrees: f64, increment: f64) -> f64 {
let snapped = (angle_degrees / increment).round() * increment;
if snapped < 0.0 {
snapped + 360.0
} else if snapped >= 360.0 {
snapped - 360.0
} else {
snapped
}
}
pub fn snap_line_endpoint(start: Point, end: Point, angle_snap_enabled: bool) -> AngleSnapResult {
let dx = end.x - start.x;
let dy = end.y - start.y;
let distance = (dx * dx + dy * dy).sqrt();
if distance < 0.001 {
return AngleSnapResult {
point: end,
angle_degrees: 0.0,
original_angle_degrees: 0.0,
snapped: false,
distance: 0.0,
};
}
let original_angle = dy.atan2(dx).to_degrees();
let original_angle_normalized = if original_angle < 0.0 { original_angle + 360.0 } else { original_angle };
if !angle_snap_enabled {
return AngleSnapResult {
point: end,
angle_degrees: original_angle_normalized,
original_angle_degrees: original_angle_normalized,
snapped: false,
distance,
};
}
let snapped_angle = snap_angle(original_angle_normalized, ANGLE_SNAP_INCREMENT);
let snapped_radians = snapped_angle.to_radians();
let snapped_point = Point::new(
start.x + distance * snapped_radians.cos(),
start.y + distance * snapped_radians.sin(),
);
let angle_diff = (snapped_angle - original_angle_normalized).abs();
let did_snap = angle_diff > 0.1 && angle_diff < 359.9;
AngleSnapResult {
point: snapped_point,
angle_degrees: snapped_angle,
original_angle_degrees: original_angle_normalized,
snapped: did_snap || angle_diff < 0.1, distance,
}
}
pub fn snap_to_grid(point: Point, grid_size: f64) -> SnapResult {
let snapped_x = (point.x / grid_size).round() * grid_size;
let snapped_y = (point.y / grid_size).round() * grid_size;
SnapResult {
point: Point::new(snapped_x, snapped_y),
snapped_x: true,
snapped_y: true,
}
}
pub fn snap_point(point: Point, mode: SnapMode, grid_size: f64) -> SnapResult {
match mode {
SnapMode::None => SnapResult::none(point),
SnapMode::Grid => snap_to_grid(point, grid_size),
SnapMode::Shapes => SnapResult::none(point), SnapMode::All => snap_to_grid(point, grid_size), }
}
pub const SHAPE_SNAP_THRESHOLD: f64 = 10.0;
#[derive(Debug, Clone, Copy)]
pub struct SnapTarget {
pub point: Point,
pub kind: SnapTargetKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnapTargetKind {
Corner,
Midpoint,
Center,
Edge,
}
pub fn get_snap_targets_from_bounds(bounds: kurbo::Rect) -> Vec<SnapTarget> {
let mut targets = Vec::with_capacity(9);
targets.push(SnapTarget { point: Point::new(bounds.x0, bounds.y0), kind: SnapTargetKind::Corner });
targets.push(SnapTarget { point: Point::new(bounds.x1, bounds.y0), kind: SnapTargetKind::Corner });
targets.push(SnapTarget { point: Point::new(bounds.x1, bounds.y1), kind: SnapTargetKind::Corner });
targets.push(SnapTarget { point: Point::new(bounds.x0, bounds.y1), kind: SnapTargetKind::Corner });
targets.push(SnapTarget { point: Point::new((bounds.x0 + bounds.x1) / 2.0, bounds.y0), kind: SnapTargetKind::Midpoint });
targets.push(SnapTarget { point: Point::new(bounds.x1, (bounds.y0 + bounds.y1) / 2.0), kind: SnapTargetKind::Midpoint });
targets.push(SnapTarget { point: Point::new((bounds.x0 + bounds.x1) / 2.0, bounds.y1), kind: SnapTargetKind::Midpoint });
targets.push(SnapTarget { point: Point::new(bounds.x0, (bounds.y0 + bounds.y1) / 2.0), kind: SnapTargetKind::Midpoint });
targets.push(SnapTarget { point: Point::new((bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0), kind: SnapTargetKind::Center });
targets
}
pub fn get_snap_targets_from_line(start: Point, end: Point) -> Vec<SnapTarget> {
vec![
SnapTarget { point: start, kind: SnapTargetKind::Corner },
SnapTarget { point: end, kind: SnapTargetKind::Corner },
SnapTarget { point: Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0), kind: SnapTargetKind::Midpoint },
]
}
pub fn snap_to_shapes(point: Point, targets: &[SnapTarget], threshold: f64) -> SnapResult {
let mut best_target: Option<&SnapTarget> = None;
let mut best_dist_sq = threshold * threshold;
for target in targets {
let dx = point.x - target.point.x;
let dy = point.y - target.point.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_target = Some(target);
}
}
if let Some(target) = best_target {
SnapResult {
point: target.point,
snapped_x: true,
snapped_y: true,
}
} else {
SnapResult::none(point)
}
}
pub fn snap_point_with_shapes(point: Point, mode: SnapMode, grid_size: f64, shape_targets: &[SnapTarget]) -> SnapResult {
match mode {
SnapMode::None => SnapResult::none(point),
SnapMode::Grid => snap_to_grid(point, grid_size),
SnapMode::Shapes => snap_to_shapes(point, shape_targets, SHAPE_SNAP_THRESHOLD),
SnapMode::All => {
let shape_result = snap_to_shapes(point, shape_targets, SHAPE_SNAP_THRESHOLD);
if shape_result.is_snapped() {
shape_result
} else {
snap_to_grid(point, grid_size)
}
}
}
}
pub fn snap_ray_to_grid_lines(
origin: Point,
angle_degrees: f64,
target_point: Point,
grid_size: f64,
) -> SnapResult {
let angle_rad = angle_degrees.to_radians();
let cos_a = angle_rad.cos();
let sin_a = angle_rad.sin();
let mut best_point = target_point;
let mut best_dist_to_target = f64::MAX;
let mut found = false;
let is_horizontal = sin_a.abs() < 0.001;
let is_vertical = cos_a.abs() < 0.001;
if is_horizontal {
let y = origin.y;
let base_grid_x = (target_point.x / grid_size).round() as i32;
for dx in -5..=5 {
let grid_x = (base_grid_x + dx) as f64 * grid_size;
if (grid_x - origin.x) * cos_a >= 0.0 || (grid_x - origin.x).abs() < 0.001 {
let candidate = Point::new(grid_x, y);
let dist = (candidate.x - target_point.x).powi(2) + (candidate.y - target_point.y).powi(2);
if dist < best_dist_to_target {
best_dist_to_target = dist;
best_point = candidate;
found = true;
}
}
}
} else if is_vertical {
let x = origin.x;
let base_grid_y = (target_point.y / grid_size).round() as i32;
for dy in -5..=5 {
let grid_y = (base_grid_y + dy) as f64 * grid_size;
if (grid_y - origin.y) * sin_a >= 0.0 || (grid_y - origin.y).abs() < 0.001 {
let candidate = Point::new(x, grid_y);
let dist = (candidate.x - target_point.x).powi(2) + (candidate.y - target_point.y).powi(2);
if dist < best_dist_to_target {
best_dist_to_target = dist;
best_point = candidate;
found = true;
}
}
}
} else {
let base_grid_x = (target_point.x / grid_size).round() as i32;
for dx in -5..=5 {
let grid_x = (base_grid_x + dx) as f64 * grid_size;
let t = (grid_x - origin.x) / cos_a;
if t >= 0.0 {
let y = origin.y + t * sin_a;
let candidate = Point::new(grid_x, y);
let dist = (candidate.x - target_point.x).powi(2) + (candidate.y - target_point.y).powi(2);
if dist < best_dist_to_target {
best_dist_to_target = dist;
best_point = candidate;
found = true;
}
}
}
let base_grid_y = (target_point.y / grid_size).round() as i32;
for dy in -5..=5 {
let grid_y = (base_grid_y + dy) as f64 * grid_size;
let t = (grid_y - origin.y) / sin_a;
if t >= 0.0 {
let x = origin.x + t * cos_a;
let candidate = Point::new(x, grid_y);
let dist = (candidate.x - target_point.x).powi(2) + (candidate.y - target_point.y).powi(2);
if dist < best_dist_to_target {
best_dist_to_target = dist;
best_point = candidate;
found = true;
}
}
}
}
if found {
SnapResult {
point: best_point,
snapped_x: true,
snapped_y: true,
}
} else {
SnapResult::none(target_point)
}
}
pub fn snap_line_endpoint_isometric(
start: Point,
end: Point,
angle_snap_enabled: bool,
grid_snap_enabled: bool,
_use_isometric_grid: bool,
grid_size: f64,
) -> AngleSnapResult {
let dx = end.x - start.x;
let dy = end.y - start.y;
let distance = (dx * dx + dy * dy).sqrt();
if distance < 0.001 {
return AngleSnapResult {
point: end,
angle_degrees: 0.0,
original_angle_degrees: 0.0,
snapped: false,
distance: 0.0,
};
}
let original_angle = dy.atan2(dx).to_degrees();
let original_angle_normalized = if original_angle < 0.0 { original_angle + 360.0 } else { original_angle };
if angle_snap_enabled && grid_snap_enabled {
let snapped_angle = snap_angle(original_angle_normalized, ANGLE_SNAP_INCREMENT);
let snapped_radians = snapped_angle.to_radians();
let angle_snapped_point = Point::new(
start.x + distance * snapped_radians.cos(),
start.y + distance * snapped_radians.sin(),
);
let grid_snap = snap_ray_to_grid_lines(start, snapped_angle, angle_snapped_point, grid_size);
if grid_snap.is_snapped() {
let new_dx = grid_snap.point.x - start.x;
let new_dy = grid_snap.point.y - start.y;
let new_distance = (new_dx * new_dx + new_dy * new_dy).sqrt();
return AngleSnapResult {
point: grid_snap.point,
angle_degrees: snapped_angle,
original_angle_degrees: original_angle_normalized,
snapped: true,
distance: new_distance,
};
}
return AngleSnapResult {
point: angle_snapped_point,
angle_degrees: snapped_angle,
original_angle_degrees: original_angle_normalized,
snapped: true,
distance,
};
}
if !angle_snap_enabled {
if grid_snap_enabled {
let grid_snap = snap_to_grid(end, grid_size);
return AngleSnapResult {
point: grid_snap.point,
angle_degrees: original_angle_normalized,
original_angle_degrees: original_angle_normalized,
snapped: grid_snap.is_snapped(),
distance,
};
}
return AngleSnapResult {
point: end,
angle_degrees: original_angle_normalized,
original_angle_degrees: original_angle_normalized,
snapped: false,
distance,
};
}
let snapped_angle = snap_angle(original_angle_normalized, ANGLE_SNAP_INCREMENT);
let snapped_radians = snapped_angle.to_radians();
let snapped_point = Point::new(
start.x + distance * snapped_radians.cos(),
start.y + distance * snapped_radians.sin(),
);
let angle_diff = (snapped_angle - original_angle_normalized).abs();
let did_snap = angle_diff > 0.1 && angle_diff < 359.9;
AngleSnapResult {
point: snapped_point,
angle_degrees: snapped_angle,
original_angle_degrees: original_angle_normalized,
snapped: did_snap || angle_diff < 0.1,
distance,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snap_to_grid() {
let result = snap_to_grid(Point::new(23.0, 47.0), 20.0);
assert_eq!(result.point, Point::new(20.0, 40.0));
assert!(result.snapped_x);
assert!(result.snapped_y);
}
#[test]
fn test_snap_to_grid_exact() {
let result = snap_to_grid(Point::new(40.0, 60.0), 20.0);
assert_eq!(result.point, Point::new(40.0, 60.0));
}
#[test]
fn test_snap_to_grid_round_up() {
let result = snap_to_grid(Point::new(31.0, 51.0), 20.0);
assert_eq!(result.point, Point::new(40.0, 60.0));
}
#[test]
fn test_snap_mode_cycle() {
assert_eq!(SnapMode::None.next(), SnapMode::Grid);
assert_eq!(SnapMode::Grid.next(), SnapMode::Shapes);
assert_eq!(SnapMode::Shapes.next(), SnapMode::All);
assert_eq!(SnapMode::All.next(), SnapMode::None);
}
#[test]
fn test_snap_mode_flags() {
assert!(!SnapMode::None.snaps_to_grid());
assert!(SnapMode::Grid.snaps_to_grid());
assert!(!SnapMode::Shapes.snaps_to_grid());
assert!(SnapMode::All.snaps_to_grid());
assert!(!SnapMode::None.snaps_to_shapes());
assert!(!SnapMode::Grid.snaps_to_shapes());
assert!(SnapMode::Shapes.snaps_to_shapes());
assert!(SnapMode::All.snaps_to_shapes());
}
#[test]
fn test_snap_angle() {
assert!((snap_angle(0.0, 15.0) - 0.0).abs() < 0.01);
assert!((snap_angle(7.0, 15.0) - 0.0).abs() < 0.01);
assert!((snap_angle(8.0, 15.0) - 15.0).abs() < 0.01);
assert!((snap_angle(22.0, 15.0) - 15.0).abs() < 0.01);
assert!((snap_angle(23.0, 15.0) - 30.0).abs() < 0.01);
assert!((snap_angle(45.0, 15.0) - 45.0).abs() < 0.01);
assert!((snap_angle(90.0, 15.0) - 90.0).abs() < 0.01);
assert!((snap_angle(180.0, 15.0) - 180.0).abs() < 0.01);
assert!((snap_angle(270.0, 15.0) - 270.0).abs() < 0.01);
assert!((snap_angle(359.0, 15.0) - 0.0).abs() < 0.01);
}
#[test]
fn test_snap_line_endpoint_horizontal() {
let start = Point::new(0.0, 0.0);
let end = Point::new(100.0, 5.0); let result = snap_line_endpoint(start, end, true);
assert!(result.snapped);
assert!((result.angle_degrees - 0.0).abs() < 0.01);
assert!((result.point.y - 0.0).abs() < 0.01);
}
#[test]
fn test_snap_line_endpoint_45_degrees() {
let start = Point::new(0.0, 0.0);
let end = Point::new(100.0, 102.0); let result = snap_line_endpoint(start, end, true);
assert!(result.snapped);
assert!((result.angle_degrees - 45.0).abs() < 0.01);
}
#[test]
fn test_snap_line_endpoint_disabled() {
let start = Point::new(0.0, 0.0);
let end = Point::new(100.0, 5.0);
let result = snap_line_endpoint(start, end, false);
assert!(!result.snapped);
assert_eq!(result.point, end);
}
#[test]
fn test_snap_line_preserves_distance() {
let start = Point::new(0.0, 0.0);
let end = Point::new(100.0, 10.0);
let result = snap_line_endpoint(start, end, true);
let original_distance = ((100.0_f64).powi(2) + (10.0_f64).powi(2)).sqrt();
assert!((result.distance - original_distance).abs() < 0.01);
}
#[test]
fn test_snap_ray_to_grid_lines_horizontal() {
let origin = Point::new(0.0, 0.0);
let target = Point::new(55.0, 0.0);
let result = snap_ray_to_grid_lines(origin, 0.0, target, 20.0);
assert!(result.is_snapped());
assert!((result.point.x - 60.0).abs() < 0.01); assert!((result.point.y - 0.0).abs() < 0.01); }
#[test]
fn test_snap_ray_to_grid_lines_45_degrees() {
let origin = Point::new(0.0, 0.0);
let target = Point::new(50.0, 50.0);
let result = snap_ray_to_grid_lines(origin, 45.0, target, 20.0);
assert!(result.is_snapped());
assert!((result.point.x - result.point.y).abs() < 0.01);
}
#[test]
fn test_snap_ray_to_grid_lines_30_degrees() {
let origin = Point::new(0.0, 0.0);
let target = Point::new(100.0, 57.7);
let result = snap_ray_to_grid_lines(origin, 30.0, target, 20.0);
assert!(result.is_snapped());
let angle = (result.point.y / result.point.x).atan().to_degrees();
assert!((angle - 30.0).abs() < 0.1);
}
#[test]
fn test_snap_line_endpoint_isometric() {
let start = Point::new(0.0, 0.0);
let end = Point::new(100.0, 5.0);
let result = snap_line_endpoint_isometric(start, end, true, true, true, 20.0);
assert!(result.snapped);
assert!((result.point.y).abs() < 0.01);
assert!((result.point.x % 20.0).abs() < 0.01 || (result.point.x % 20.0 - 20.0).abs() < 0.01);
}
#[test]
fn test_snap_line_endpoint_isometric_45_degrees() {
let start = Point::new(0.0, 0.0);
let end = Point::new(100.0, 102.0);
let result = snap_line_endpoint_isometric(start, end, true, true, true, 20.0);
assert!(result.snapped);
assert!((result.point.x - result.point.y).abs() < 0.1);
let on_x_grid = (result.point.x % 20.0).abs() < 0.01 || (result.point.x % 20.0 - 20.0).abs() < 0.01;
let on_y_grid = (result.point.y % 20.0).abs() < 0.01 || (result.point.y % 20.0 - 20.0).abs() < 0.01;
assert!(on_x_grid || on_y_grid);
}
}