cranpose_foundation/nodes/input/gestures/
transform.rs1use crate::nodes::input::{PointerEvent, PointerEventKind, PointerId};
20use cranpose_ui_graphics::Point;
21
22const MIN_ZOOM_SPREAD: f32 = 1.0;
26
27#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum TransformGestureEvent {
30 None,
32 Transform {
34 pan: Point,
36 zoom: f32,
39 centroid: Point,
45 pointer_count: usize,
47 },
48 Ended,
50}
51
52#[derive(Clone, Debug, Default)]
54pub struct TransformGesture {
55 pointers: Vec<(PointerId, Point)>,
56}
57
58impl TransformGesture {
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 pub fn pointer_count(&self) -> usize {
65 self.pointers.len()
66 }
67
68 pub fn centroid(&self) -> Option<Point> {
70 if self.pointers.is_empty() {
71 None
72 } else {
73 Some(centroid_of(&self.pointers))
74 }
75 }
76
77 pub fn handle_event(&mut self, event: &PointerEvent) -> TransformGestureEvent {
79 match event.kind {
80 PointerEventKind::Down => {
81 match self.pointers.iter_mut().find(|(id, _)| *id == event.id) {
82 Some(entry) => entry.1 = event.position,
83 None => self.pointers.push((event.id, event.position)),
84 }
85 TransformGestureEvent::None
86 }
87 PointerEventKind::Move => {
88 let Some(index) = self.pointers.iter().position(|(id, _)| *id == event.id) else {
89 return TransformGestureEvent::None;
90 };
91
92 let old_centroid = centroid_of(&self.pointers);
93 let old_spread = mean_spread(&self.pointers, old_centroid);
94
95 self.pointers[index].1 = event.position;
96
97 let new_centroid = centroid_of(&self.pointers);
98 let new_spread = mean_spread(&self.pointers, new_centroid);
99
100 let pan = Point {
101 x: new_centroid.x - old_centroid.x,
102 y: new_centroid.y - old_centroid.y,
103 };
104 let zoom = if self.pointers.len() >= 2
105 && old_spread > MIN_ZOOM_SPREAD
106 && new_spread > MIN_ZOOM_SPREAD
107 {
108 new_spread / old_spread
109 } else {
110 1.0
111 };
112
113 if pan.x == 0.0 && pan.y == 0.0 && zoom == 1.0 {
114 TransformGestureEvent::None
115 } else {
116 TransformGestureEvent::Transform {
117 pan,
118 zoom,
119 centroid: old_centroid,
120 pointer_count: self.pointers.len(),
121 }
122 }
123 }
124 PointerEventKind::Up | PointerEventKind::Cancel => {
125 self.pointers.retain(|(id, _)| *id != event.id);
126 if self.pointers.is_empty() {
127 TransformGestureEvent::Ended
128 } else {
129 TransformGestureEvent::None
130 }
131 }
132 _ => TransformGestureEvent::None,
133 }
134 }
135
136 pub fn reset(&mut self) {
138 self.pointers.clear();
139 }
140}
141
142fn centroid_of(pointers: &[(PointerId, Point)]) -> Point {
143 let count = pointers.len() as f32;
144 let mut sum = Point { x: 0.0, y: 0.0 };
145 for (_, position) in pointers {
146 sum.x += position.x;
147 sum.y += position.y;
148 }
149 Point {
150 x: sum.x / count,
151 y: sum.y / count,
152 }
153}
154
155fn mean_spread(pointers: &[(PointerId, Point)], centroid: Point) -> f32 {
156 let count = pointers.len() as f32;
157 let mut sum = 0.0;
158 for (_, position) in pointers {
159 let dx = position.x - centroid.x;
160 let dy = position.y - centroid.y;
161 sum += (dx * dx + dy * dy).sqrt();
162 }
163 sum / count
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn event(kind: PointerEventKind, id: u64, x: f32, y: f32) -> PointerEvent {
171 let mut event = PointerEvent::new(kind, Point { x, y }, Point { x, y });
172 event.id = id;
173 event
174 }
175
176 #[test]
177 fn pinch_out_reports_zoom_and_focal_centroid() {
178 let mut gesture = TransformGesture::new();
179 gesture.handle_event(&event(PointerEventKind::Down, 0, 100.0, 100.0));
180 gesture.handle_event(&event(PointerEventKind::Down, 1, 200.0, 100.0));
181
182 let step = gesture.handle_event(&event(PointerEventKind::Move, 1, 300.0, 100.0));
184 match step {
185 TransformGestureEvent::Transform {
186 pan,
187 zoom,
188 centroid,
189 pointer_count,
190 } => {
191 assert!((zoom - 2.0).abs() < 1e-5, "spread doubled, got zoom={zoom}");
192 assert!((pan.x - 50.0).abs() < 1e-5 && pan.y.abs() < 1e-5, "{pan:?}");
193 assert_eq!(centroid, Point { x: 150.0, y: 100.0 });
195 assert_eq!(pointer_count, 2);
196 }
197 other => panic!("expected Transform, got {other:?}"),
198 }
199 }
200
201 #[test]
202 fn pinch_in_reports_zoom_below_one() {
203 let mut gesture = TransformGesture::new();
204 gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
205 gesture.handle_event(&event(PointerEventKind::Down, 1, 0.0, 200.0));
206
207 let step = gesture.handle_event(&event(PointerEventKind::Move, 1, 0.0, 100.0));
208 match step {
209 TransformGestureEvent::Transform { zoom, .. } => {
210 assert!((zoom - 0.5).abs() < 1e-5, "spread halved, got zoom={zoom}");
211 }
212 other => panic!("expected Transform, got {other:?}"),
213 }
214 }
215
216 #[test]
217 fn two_finger_pan_steps_compose_to_pure_pan() {
218 let mut gesture = TransformGesture::new();
219 gesture.handle_event(&event(PointerEventKind::Down, 0, 100.0, 100.0));
220 gesture.handle_event(&event(PointerEventKind::Down, 1, 200.0, 100.0));
221
222 let mut total_pan = Point { x: 0.0, y: 0.0 };
224 let mut total_zoom = 1.0;
225 for step in [
226 gesture.handle_event(&event(PointerEventKind::Move, 0, 110.0, 100.0)),
227 gesture.handle_event(&event(PointerEventKind::Move, 1, 210.0, 100.0)),
228 ] {
229 if let TransformGestureEvent::Transform { pan, zoom, .. } = step {
230 total_pan.x += pan.x;
231 total_pan.y += pan.y;
232 total_zoom *= zoom;
233 }
234 }
235
236 assert!(
237 (total_pan.x - 10.0).abs() < 1e-4 && total_pan.y.abs() < 1e-4,
238 "steps must compose to the +10 centroid pan, got {total_pan:?}"
239 );
240 assert!(
241 (total_zoom - 1.0).abs() < 1e-4,
242 "pure pan must compose to zoom 1.0, got {total_zoom}"
243 );
244 }
245
246 #[test]
247 fn single_finger_move_is_pan_only() {
248 let mut gesture = TransformGesture::new();
249 gesture.handle_event(&event(PointerEventKind::Down, 0, 50.0, 50.0));
250
251 let step = gesture.handle_event(&event(PointerEventKind::Move, 0, 62.0, 45.0));
252 assert_eq!(
253 step,
254 TransformGestureEvent::Transform {
255 pan: Point { x: 12.0, y: -5.0 },
256 zoom: 1.0,
257 centroid: Point { x: 50.0, y: 50.0 },
258 pointer_count: 1,
259 }
260 );
261 }
262
263 #[test]
264 fn untracked_pointer_moves_are_ignored() {
265 let mut gesture = TransformGesture::new();
266 gesture.handle_event(&event(PointerEventKind::Down, 0, 50.0, 50.0));
267
268 let step = gesture.handle_event(&event(PointerEventKind::Move, 7, 500.0, 500.0));
269 assert_eq!(step, TransformGestureEvent::None);
270 }
271
272 #[test]
273 fn gesture_ends_when_last_pointer_lifts() {
274 let mut gesture = TransformGesture::new();
275 gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
276 gesture.handle_event(&event(PointerEventKind::Down, 1, 100.0, 0.0));
277
278 assert_eq!(
279 gesture.handle_event(&event(PointerEventKind::Up, 1, 100.0, 0.0)),
280 TransformGestureEvent::None
281 );
282 assert_eq!(gesture.pointer_count(), 1);
283 assert_eq!(
284 gesture.handle_event(&event(PointerEventKind::Up, 0, 0.0, 0.0)),
285 TransformGestureEvent::Ended
286 );
287 assert_eq!(gesture.pointer_count(), 0);
288 }
289
290 #[test]
291 fn lifting_one_finger_does_not_jump_the_pan() {
292 let mut gesture = TransformGesture::new();
293 gesture.handle_event(&event(PointerEventKind::Down, 0, 0.0, 0.0));
294 gesture.handle_event(&event(PointerEventKind::Down, 1, 100.0, 0.0));
295 gesture.handle_event(&event(PointerEventKind::Up, 1, 100.0, 0.0));
296
297 let step = gesture.handle_event(&event(PointerEventKind::Move, 0, 5.0, 0.0));
300 match step {
301 TransformGestureEvent::Transform { pan, zoom, .. } => {
302 assert!((pan.x - 5.0).abs() < 1e-5 && pan.y.abs() < 1e-5, "{pan:?}");
303 assert_eq!(zoom, 1.0);
304 }
305 other => panic!("expected Transform, got {other:?}"),
306 }
307 }
308}