Skip to main content

xgpui_canvas/
snapping.rs

1use crate::{Point, Scene, WorldBounds};
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4pub enum Axis {
5    X,
6    Y,
7}
8
9/// World-space guide: constant coordinate on axis, start/end on the other axis.
10#[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    /// Snap group edges/centers to nearby stationary item edges/centers.
27    /// Tolerance and search radius are world units (divide logical pixels by zoom).
28    /// Exclude moving items. Each axis chooses the closest match independently;
29    /// equal distances retain back-to-front query order for deterministic results.
30    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}