1use crate::{Point, Scene, WorldBounds};
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4pub enum Axis {
5 X,
6 Y,
7}
8
9#[derive(Clone, Copy, Debug)]
11pub struct AlignmentGuide {
12 pub axis: Axis,
13 pub coordinate: f64,
14 pub start: f64,
15 pub end: f64,
16}
17
18#[derive(Clone, Copy, Debug)]
19pub struct SnapResult {
20 pub delta: Point,
21 pub x: Option<AlignmentGuide>,
22 pub y: Option<AlignmentGuide>,
23}
24
25impl<T> Scene<T> {
26 pub fn snap_translation(
31 &self,
32 moving: WorldBounds,
33 delta: Point,
34 tolerance: f64,
35 radius: f64,
36 exclude: impl Fn(&T) -> bool,
37 ) -> SnapResult {
38 let mut result = SnapResult {
39 delta,
40 x: None,
41 y: None,
42 };
43 if !tolerance.is_finite() || tolerance < 0. || !radius.is_finite() || radius < 0. {
44 return result;
45 }
46 let proposed = moving.translated(delta);
47 let search = WorldBounds {
48 min: Point::new(proposed.min.x - radius, proposed.min.y - radius),
49 max: Point::new(proposed.max.x + radius, proposed.max.y + radius),
50 };
51 let mut x = None;
52 let mut y = None;
53 for item in self.query(search).filter(|item| !exclude(&item.data)) {
54 nearest(
55 proposed.min.x,
56 proposed.max.x,
57 item.bounds.min.x,
58 item.bounds.max.x,
59 tolerance,
60 item.bounds,
61 &mut x,
62 );
63 nearest(
64 proposed.min.y,
65 proposed.max.y,
66 item.bounds.min.y,
67 item.bounds.max.y,
68 tolerance,
69 item.bounds,
70 &mut y,
71 );
72 }
73 if let Some((correction, _, _)) = x {
74 result.delta.x += correction;
75 }
76 if let Some((correction, _, _)) = y {
77 result.delta.y += correction;
78 }
79 let snapped = moving.translated(result.delta);
80 result.x = x.map(|(_, coordinate, bounds)| AlignmentGuide {
81 axis: Axis::X,
82 coordinate,
83 start: snapped.min.y.min(bounds.min.y),
84 end: snapped.max.y.max(bounds.max.y),
85 });
86 result.y = y.map(|(_, coordinate, bounds)| AlignmentGuide {
87 axis: Axis::Y,
88 coordinate,
89 start: snapped.min.x.min(bounds.min.x),
90 end: snapped.max.x.max(bounds.max.x),
91 });
92 result
93 }
94}
95
96fn nearest(
97 min: f64,
98 max: f64,
99 target_min: f64,
100 target_max: f64,
101 tolerance: f64,
102 bounds: WorldBounds,
103 best: &mut Option<(f64, f64, WorldBounds)>,
104) {
105 for source in [min, min * 0.5 + max * 0.5, max] {
106 for target in [target_min, target_min * 0.5 + target_max * 0.5, target_max] {
107 let correction = target - source;
108 if correction.abs() <= tolerance
109 && best.is_none_or(|(previous, _, _)| correction.abs() < previous.abs())
110 {
111 *best = Some((correction, target, bounds));
112 }
113 }
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 #[test]
121 fn snapping_excludes_selection_and_uses_nearest_edge_or_center() {
122 let moving = WorldBounds {
123 min: Point::new(0., 0.),
124 max: Point::new(100., 100.),
125 };
126 let target = WorldBounds {
127 min: Point::new(200., 200.),
128 max: Point::new(300., 300.),
129 };
130 let scene = Scene::new([
131 crate::SceneItem {
132 bounds: moving,
133 data: 0,
134 },
135 crate::SceneItem {
136 bounds: target,
137 data: 1,
138 },
139 ]);
140 let snap = scene.snap_translation(moving, Point::new(97., 149.), 6., 200., |id| *id == 0);
141 assert_eq!(snap.delta, Point::new(100., 150.));
142 assert_eq!(snap.x.unwrap().coordinate, 200.);
143 assert_eq!(snap.y.unwrap().coordinate, 200.);
144 let free = scene.snap_translation(moving, Point::new(90., 140.), 6., 200., |_| false);
145 assert_eq!(free.delta, Point::new(90., 140.));
146 let excluded = scene.snap_translation(moving, Point::new(97., 149.), 6., 200., |_| true);
147 assert!(excluded.x.is_none() && excluded.y.is_none());
148 }
149
150 #[test]
151 fn caller_can_keep_snap_tolerance_constant_in_screen_pixels() {
152 let moving = WorldBounds {
153 min: Point::default(),
154 max: Point::new(100., 100.),
155 };
156 let scene = Scene::new([crate::SceneItem {
157 bounds: WorldBounds {
158 min: Point::new(200., 0.),
159 max: Point::new(300., 100.),
160 },
161 data: (),
162 }]);
163 for zoom in [0.1, 1., 10.] {
164 let snap = scene.snap_translation(
165 moving,
166 Point::new(100. - 5. / zoom, 0.),
167 6. / zoom,
168 200. / zoom,
169 |_| false,
170 );
171 assert!((snap.delta.x - 100.).abs() < 1e-8);
172 let free = scene.snap_translation(
173 moving,
174 Point::new(100. - 7. / zoom, 0.),
175 6. / zoom,
176 200. / zoom,
177 |_| false,
178 );
179 assert!(free.x.is_none());
180 }
181 }
182}