From dd6980bacb5d5ba132ecd1c1d2708c47995b392d Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sat, 8 May 2021 23:31:31 +0200 Subject: [PATCH] Implement non-proportional multitouch pinch zooming --- egui/src/input_state.rs | 26 +++++++- egui/src/input_state/touch_state.rs | 96 ++++++++++++++++++++++++++--- egui/src/widgets/plot/mod.rs | 9 ++- egui/src/widgets/plot/transform.rs | 11 ++-- 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/egui/src/input_state.rs b/egui/src/input_state.rs index ed44d080..d8162716 100644 --- a/egui/src/input_state.rs +++ b/egui/src/input_state.rs @@ -130,7 +130,7 @@ impl InputState { } /// Zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture). - /// * `zoom = 1`: no change (default). + /// * `zoom = 1`: no change /// * `zoom < 1`: pinch together /// * `zoom > 1`: pinch spread #[inline(always)] @@ -144,6 +144,30 @@ impl InputState { .unwrap_or(self.raw.zoom_delta) } + /// 2D non-proportional zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture). + /// + /// For multitouch devices the user can do a horizontal or vertical pinch gesture. + /// In these cases a non-proportional zoom factor is a available. + /// In other cases, this reverts to `Vec2::splat(self.zoom_delta())`. + /// + /// For horizontal pinches, this will return `[z, 1]`, + /// for vertical pinches this will return `[1, z]`, + /// and otherwise this will return `[z, z]`, + /// where `z` is the zoom factor: + /// * `zoom = 1`: no change + /// * `zoom < 1`: pinch together + /// * `zoom > 1`: pinch spread + #[inline(always)] + pub fn zoom_delta_2d(&self) -> Vec2 { + // If a multi touch gesture is detected, it measures the exact and linear proportions of + // the distances of the finger tips. It is therefore potentially more accurate than + // `raw.zoom_delta` which is based on the `ctrl-scroll` event which, in turn, may be + // synthesized from an original touch gesture. + self.multi_touch() + .map(|touch| touch.zoom_delta_2d) + .unwrap_or_else(|| Vec2::splat(self.raw.zoom_delta)) + } + pub fn wants_repaint(&self) -> bool { self.pointer.wants_repaint() || self.scroll_delta != Vec2::ZERO || !self.events.is_empty() } diff --git a/egui/src/input_state/touch_state.rs b/egui/src/input_state/touch_state.rs index c3efb7bb..41d1a590 100644 --- a/egui/src/input_state/touch_state.rs +++ b/egui/src/input_state/touch_state.rs @@ -11,20 +11,36 @@ use epaint::emath::{Pos2, Vec2}; pub struct MultiTouchInfo { /// Point in time when the gesture started. pub start_time: f64, + /// Position of the pointer at the time the gesture started. pub start_pos: Pos2, + /// Number of touches (fingers) on the surface. Value is ≥ 2 since for a single touch no /// `MultiTouchInfo` is created. pub num_touches: usize, - /// Zoom factor (Pinch or Zoom). Moving fingers closer together or further appart will change - /// this value. This is a relative value, comparing the average distances of the fingers in - /// the current and previous frame. If the fingers did not move since the previous frame, - /// this value is `1.0`. + + /// Proportional zoom factor (pinch gesture). + /// * `zoom = 1`: no change + /// * `zoom < 1`: pinch together + /// * `zoom > 1`: pinch spread pub zoom_delta: f32, + + /// 2D non-proportional zoom factor (pinch gesture). + /// + /// For horizontal pinches, this will return `[z, 1]`, + /// for vertical pinches this will return `[1, z]`, + /// and otherwise this will return `[z, z]`, + /// where `z` is the zoom factor: + /// * `zoom = 1`: no change + /// * `zoom < 1`: pinch together + /// * `zoom > 1`: pinch spread + pub zoom_delta_2d: Vec2, + /// Rotation in radians. Moving fingers around each other will change this value. This is a /// relative value, comparing the orientation of fingers in the current frame with the previous /// frame. If all fingers are resting, this value is `0.0`. pub rotation_delta: f32, + /// Relative movement (comparing previous frame and current frame) of the average position of /// all touch points. Without movement this value is `Vec2::ZERO`. /// @@ -34,6 +50,7 @@ pub struct MultiTouchInfo { /// the pointer, but touch movement is always measured in the units delivered by the device, /// and may depend on hardware and system settings. pub translation_delta: Vec2, + /// Current force of the touch (average of the forces of the individual fingers). This is a /// value in the interval `[0.0 .. =1.0]`. /// @@ -67,6 +84,7 @@ pub(crate) struct TouchState { struct GestureState { start_time: f64, start_pointer_pos: Pos2, + pinch_type: PinchType, previous: Option, current: DynGestureState, } @@ -74,7 +92,10 @@ struct GestureState { /// Gesture data that can change over time #[derive(Clone, Copy, Debug)] struct DynGestureState { + /// used for proportional zooming avg_distance: f32, + /// used for non-proportional zooming + avg_abs_distance2: Vec2, avg_pos: Pos2, avg_force: f32, heading: f32, @@ -154,11 +175,27 @@ impl TouchState { // changed. In this case, we take `current` as `previous`, pretending that there // was no change for the current frame. let state_previous = state.previous.unwrap_or(state.current); + + let zoom_delta = state.current.avg_distance / state_previous.avg_distance; + + let zoom_delta2 = match state.pinch_type { + PinchType::Horizontal => Vec2::new( + state.current.avg_abs_distance2.x / state_previous.avg_abs_distance2.x, + 1.0, + ), + PinchType::Vertical => Vec2::new( + 1.0, + state.current.avg_abs_distance2.y / state_previous.avg_abs_distance2.y, + ), + PinchType::Proportional => Vec2::splat(zoom_delta), + }; + MultiTouchInfo { start_time: state.start_time, start_pos: state.start_pointer_pos, num_touches: self.active_touches.len(), - zoom_delta: state.current.avg_distance / state_previous.avg_distance, + zoom_delta, + zoom_delta_2d: zoom_delta2, rotation_delta: normalized_angle(state.current.heading, state_previous.heading), translation_delta: state.current.avg_pos - state_previous.avg_pos, force: state.current.avg_force, @@ -177,6 +214,7 @@ impl TouchState { self.gesture_state = Some(GestureState { start_time: time, start_pointer_pos: pointer_pos, + pinch_type: PinchType::classify(&self.active_touches), previous: None, current: dyn_state, }); @@ -187,16 +225,18 @@ impl TouchState { } } + /// `None` if less than two fingers fn calc_dynamic_state(&self) -> Option { let num_touches = self.active_touches.len(); if num_touches < 2 { None } else { let mut state = DynGestureState { - avg_distance: 0., + avg_distance: 0.0, + avg_abs_distance2: Vec2::ZERO, avg_pos: Pos2::ZERO, - avg_force: 0., - heading: 0., + avg_force: 0.0, + heading: 0.0, }; let num_touches_recip = 1. / num_touches as f32; @@ -213,8 +253,11 @@ impl TouchState { // second pass: calculate distances from center: for touch in self.active_touches.values() { state.avg_distance += state.avg_pos.distance(touch.pos); + state.avg_abs_distance2.x += (state.avg_pos.x - touch.pos.x).abs(); + state.avg_abs_distance2.y += (state.avg_pos.y - touch.pos.y).abs(); } state.avg_distance *= num_touches_recip; + state.avg_abs_distance2 *= num_touches_recip; // Calculate the direction from the first touch to the center position. // This is not the perfect way of calculating the direction if more than two fingers @@ -271,3 +314,40 @@ fn normalizing_angle_from_350_to_0_yields_10() { <= 5. * f32::EPSILON // many conversions (=divisions) involved => high error rate ); } + +#[derive(Clone, Debug)] +enum PinchType { + Horizontal, + Vertical, + Proportional, +} + +impl PinchType { + fn classify(touches: &BTreeMap) -> Self { + // For non-proportional 2d zooming: + // If the user is pinching with two fingers that have roughly the same Y coord, + // then the Y zoom is unstable and should be 1. + // Similarly, if the fingers are directly above/below each other, + // we should only zoom on the Y axis. + // If the fingers are roughly on a diagonal, we revert to the proportional zooming. + + if touches.len() == 2 { + let mut touches = touches.values(); + let t0 = touches.next().unwrap().pos; + let t1 = touches.next().unwrap().pos; + + let dx = (t0.x - t1.x).abs(); + let dy = (t0.y - t1.y).abs(); + + if dx > 3.0 * dy { + Self::Horizontal + } else if dy > 3.0 * dx { + Self::Vertical + } else { + Self::Proportional + } + } else { + Self::Proportional + } + } +} diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index bfda24c6..39a38aa9 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -418,12 +418,17 @@ impl Widget for Plot { // Zooming if allow_zoom { if let Some(hover_pos) = response.hover_pos() { - let zoom_factor = ui.input().zoom_delta(); + let zoom_factor = if data_aspect.is_some() { + Vec2::splat(ui.input().zoom_delta()) + } else { + ui.input().zoom_delta_2d() + }; #[allow(clippy::float_cmp)] - if zoom_factor != 1.0 { + if zoom_factor != Vec2::splat(1.0) { transform.zoom(zoom_factor, hover_pos); auto_bounds = false; } + let scroll_delta = ui.input().scroll_delta; if scroll_delta != Vec2::ZERO { transform.translate_bounds(-scroll_delta); diff --git a/egui/src/widgets/plot/transform.rs b/egui/src/widgets/plot/transform.rs index c9b82183..138e75d0 100644 --- a/egui/src/widgets/plot/transform.rs +++ b/egui/src/widgets/plot/transform.rs @@ -161,15 +161,14 @@ impl ScreenTransform { } /// Zoom by a relative factor with the given screen position as center. - pub fn zoom(&mut self, zoom_factor: f32, center: Pos2) { - let zoom_factor = zoom_factor as f64; + pub fn zoom(&mut self, zoom_factor: Vec2, center: Pos2) { let center = self.value_from_position(center); let mut new_bounds = self.bounds; - new_bounds.min[0] = center.x + (new_bounds.min[0] - center.x) / zoom_factor; - new_bounds.max[0] = center.x + (new_bounds.max[0] - center.x) / zoom_factor; - new_bounds.min[1] = center.y + (new_bounds.min[1] - center.y) / zoom_factor; - new_bounds.max[1] = center.y + (new_bounds.max[1] - center.y) / zoom_factor; + new_bounds.min[0] = center.x + (new_bounds.min[0] - center.x) / (zoom_factor.x as f64); + new_bounds.max[0] = center.x + (new_bounds.max[0] - center.x) / (zoom_factor.x as f64); + new_bounds.min[1] = center.y + (new_bounds.min[1] - center.y) / (zoom_factor.y as f64); + new_bounds.max[1] = center.y + (new_bounds.max[1] - center.y) / (zoom_factor.y as f64); if new_bounds.is_valid() { self.bounds = new_bounds;