From d81f8446d153fa08e9182bb00e6bcd97abdff85a Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Thu, 22 Oct 2026 14:37:44 +0200 Subject: [PATCH 1/2] Handle touch input, with two fingers to pan and zoom --- neuronify-core/src/app.rs | 166 ++++++++++++++++++++++-------- neuronify-core/src/input.rs | 194 ++++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 39 deletions(-) diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index aa5a7cee..b415dae7 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -19,13 +19,13 @@ use std::io::Write; use std::path::PathBuf; use std::thread; use visula::winit::dpi::PhysicalPosition; -use visula::winit::event::{ElementState, Event, MouseButton, WindowEvent}; +use visula::winit::event::{ElementState, Event, MouseButton, TouchPhase, WindowEvent}; use visula::{ winit::keyboard::ModifiersKeyState, CustomEvent, InstanceBuffer, LineGeometry, LineMaterial, Lines, RenderData, Renderable, SphereGeometry, SphereMaterial, Spheres, }; -use crate::input::{Keyboard, Mouse}; +use crate::input::{Keyboard, Mouse, TouchGesture, Touches}; use crate::simulation; pub struct Neuronify { @@ -46,7 +46,7 @@ pub struct Neuronify { pub last_update: DateTime, pub fps: f64, pub edit_enabled: bool, - pub last_touch_points: Option<((f64, f64), (f64, f64))>, + pub touches: Touches, pub move_origin: Option, pub active_entity: Option, pub dragging_entity: Option, @@ -80,6 +80,63 @@ fn within_selection_range( } } +fn ground_position(application: &visula::Application, position: PhysicalPosition) -> Vec3 { + let ndc_x = 2.0 * position.x as f32 / application.config.width as f32 - 1.0; + let ndc_y = 1.0 - 2.0 * position.y as f32 / application.config.height as f32; + let ray_clip = glam::Vec4::new(ndc_x, ndc_y, -1.0, 1.0); + let aspect_ratio = application.config.width as f32 / application.config.height as f32; + let inv_projection = application + .camera_controller + .projection_matrix(aspect_ratio) + .inverse(); + + let ray_eye = inv_projection * ray_clip; + let ray_eye = glam::Vec4::new(ray_eye.x, ray_eye.y, -1.0, 0.0); + let inv_view_matrix = application.camera_controller.view_matrix().inverse(); + let ray_world = inv_view_matrix * ray_eye; + let ray_world = Vec3::new(ray_world.x, ray_world.y, ray_world.z).normalize(); + let ray_origin = application.camera_controller.position(); + let t = -ray_origin.y / ray_world.y; + ray_origin + t * ray_world +} + +fn gui_takes_press(application: &visula::Application, position: PhysicalPosition) -> bool { + let context = application.egui_renderer.state.egui_ctx(); + let point = egui::pos2(position.x as f32, position.y as f32) / context.pixels_per_point(); + let radius = context.global_style().interaction.interact_radius; + context.memory(|memory| { + memory.areas().visible_layer_ids().into_iter().any(|layer| { + layer.order != egui::Order::Background + && memory + .area_rect(layer.id) + .is_some_and(|rect| rect.distance_to_pos(point) <= radius) + }) + }) +} + +fn pinch( + application: &mut visula::Application, + previous: [PhysicalPosition; 2], + current: [PhysicalPosition; 2], +) { + let spread = |[a, b]: [PhysicalPosition; 2]| (a.x - b.x).hypot(a.y - b.y); + let midpoint = |[a, b]: [PhysicalPosition; 2]| { + PhysicalPosition::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0) + }; + let anchor = ground_position(application, midpoint(previous)); + let camera = &mut application.camera_controller; + if spread(previous) > 0.0 && spread(current) > 0.0 { + let zoom = (spread(previous) / spread(current)) as f32; + camera.current_transform.distance = (camera.current_transform.distance * zoom) + .clamp(CAMERA_MIN_DISTANCE, CAMERA_MAX_DISTANCE); + camera.target_transform.distance = camera.current_transform.distance; + } + let under_fingers = ground_position(application, midpoint(current)); + let camera = &mut application.camera_controller; + camera.current_transform.center += anchor - under_fingers; + camera.target_transform.center = camera.current_transform.center; +} + impl Neuronify { pub fn new(application: &mut visula::Application) -> Neuronify { application.camera_controller.enabled = false; @@ -182,7 +239,7 @@ impl Neuronify { last_update: Utc::now(), fps: 60.0, edit_enabled: true, - last_touch_points: None, + touches: Touches::default(), move_origin: None, active_entity: None, dragging_entity: None, @@ -192,7 +249,47 @@ impl Neuronify { } } + fn press_pointer(&mut self, application: &mut visula::Application, pressed: bool) { + self.mouse.left_down = pressed; + self.mouse.delta_position = None; + self.handle_tool(application); + } + + fn move_pointer( + &mut self, + application: &mut visula::Application, + position: PhysicalPosition, + ) { + self.mouse.delta_position = self.mouse.position.map(|previous| { + PhysicalPosition::new(position.x - previous.x, position.y - previous.y) + }); + self.mouse.position = Some(position); + self.handle_tool(application); + } + + fn release_tools(&mut self) { + self.stimulation_tool = None; + self.connection_tool = None; + self.previous_creation = None; + self.move_origin = None; + self.dragging_entity = None; + self.resizing_voltmeter = None; + } + + fn sync_pointer_with_gui(&mut self, context: &egui::Context) { + let held = context.input(|input| input.pointer.any_down() || input.any_touches()); + if !held { + self.touches = Touches::default(); + self.mouse.left_down = false; + self.release_tools(); + } + } + fn handle_tool(&mut self, application: &mut visula::Application) { + if !self.mouse.left_down { + self.release_tools(); + return; + } let Neuronify { tool, mouse, @@ -207,39 +304,13 @@ impl Neuronify { resizing_voltmeter, .. } = self; - if !mouse.left_down { - *stimulation_tool = None; - *connection_tool = None; - *previous_creation = None; - *move_origin = None; - *dragging_entity = None; - *resizing_voltmeter = None; - return; - } let mouse_physical_position = match mouse.position { Some(p) => p, None => { return; } }; - let ndc_x = 2.0 * mouse_physical_position.x as f32 / application.config.width as f32 - 1.0; - let ndc_y = 1.0 - 2.0 * mouse_physical_position.y as f32 / application.config.height as f32; - let ray_clip = glam::Vec4::new(ndc_x, ndc_y, -1.0, 1.0); - let aspect_ratio = application.config.width as f32 / application.config.height as f32; - let inv_projection = application - .camera_controller - .projection_matrix(aspect_ratio) - .inverse(); - - let ray_eye = inv_projection * ray_clip; - let ray_eye = glam::Vec4::new(ray_eye.x, ray_eye.y, -1.0, 0.0); - let inv_view_matrix = application.camera_controller.view_matrix().inverse(); - let ray_world = inv_view_matrix * ray_eye; - let ray_world = Vec3::new(ray_world.x, ray_world.y, ray_world.z).normalize(); - let ray_origin = application.camera_controller.position(); - let t = -ray_origin.y / ray_world.y; - let intersection = ray_origin + t * ray_world; - let mouse_position = intersection; + let mouse_position = ground_position(application, mouse_physical_position); let minimum_distance = match tool { Tool::Axon => MIN_CREATION_DISTANCE_AXON, @@ -988,6 +1059,7 @@ impl visula::Simulation for Neuronify { } fn gui(&mut self, _application: &visula::Application, context: &egui::Context) { + self.sync_pointer_with_gui(context); if let Some(error) = &self.load_error { egui::Window::new("Could not load file").show(context, |ui| { ui.label(error); @@ -1299,9 +1371,7 @@ impl visula::Simulation for Neuronify { }, .. } => { - self.mouse.left_down = *state == ElementState::Pressed; - self.mouse.delta_position = None; - self.handle_tool(application); + self.press_pointer(application, *state == ElementState::Pressed); } Event::WindowEvent { event: WindowEvent::ModifiersChanged(state), @@ -1314,11 +1384,29 @@ impl visula::Simulation for Neuronify { event: WindowEvent::CursorMoved { position, .. }, .. } => { - self.mouse.delta_position = self.mouse.position.map(|previous| { - PhysicalPosition::new(position.x - previous.x, position.y - previous.y) - }); - self.mouse.position = Some(*position); - self.handle_tool(application); + self.move_pointer(application, *position); + } + Event::WindowEvent { + event: WindowEvent::Touch(touch), + .. + } => { + if touch.phase == TouchPhase::Started + && gui_takes_press(application, touch.location) + { + return; + } + match self.touches.update(touch) { + TouchGesture::Press(position) => { + self.move_pointer(application, position); + self.press_pointer(application, true); + } + TouchGesture::Drag(position) => self.move_pointer(application, position), + TouchGesture::Release => self.press_pointer(application, false), + TouchGesture::Pinch { previous, current } => { + pinch(application, previous, current) + } + TouchGesture::None => {} + } } Event::WindowEvent { event: WindowEvent::MouseWheel { delta, .. }, diff --git a/neuronify-core/src/input.rs b/neuronify-core/src/input.rs index 6bf3c9c0..8619f932 100644 --- a/neuronify-core/src/input.rs +++ b/neuronify-core/src/input.rs @@ -1,4 +1,5 @@ use visula::winit::dpi::PhysicalPosition; +use visula::winit::event::{Touch, TouchPhase}; #[derive(Clone, Copy, Debug)] pub struct Mouse { @@ -10,3 +11,196 @@ pub struct Mouse { pub struct Keyboard { pub shift_down: bool, } + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TouchGesture { + Press(PhysicalPosition), + Drag(PhysicalPosition), + Release, + Pinch { + previous: [PhysicalPosition; 2], + current: [PhysicalPosition; 2], + }, + None, +} + +#[derive(Debug, Default)] +pub struct Touches { + fingers: Vec<(u64, PhysicalPosition)>, + pinched: bool, +} + +impl Touches { + pub fn update(&mut self, touch: &Touch) -> TouchGesture { + let index = self.fingers.iter().position(|(id, _)| *id == touch.id); + match (touch.phase, index) { + (TouchPhase::Started, None) => { + self.fingers.push((touch.id, touch.location)); + match self.fingers.len() { + 1 => TouchGesture::Press(touch.location), + 2 => { + self.pinched = true; + TouchGesture::Release + } + _ => TouchGesture::None, + } + } + (TouchPhase::Started, Some(_)) => TouchGesture::None, + (TouchPhase::Moved, Some(index)) => { + let previous = self.pinch_points(); + self.fingers[index].1 = touch.location; + match (previous, self.pinch_points()) { + (Some(previous), Some(current)) if index < 2 => { + TouchGesture::Pinch { previous, current } + } + _ if !self.pinched => TouchGesture::Drag(touch.location), + _ => TouchGesture::None, + } + } + (TouchPhase::Ended | TouchPhase::Cancelled, Some(index)) => { + self.fingers.remove(index); + let released = self.fingers.is_empty() && !self.pinched; + if self.fingers.is_empty() { + self.pinched = false; + } + if released { + TouchGesture::Release + } else { + TouchGesture::None + } + } + (_, None) => TouchGesture::None, + } + } + + fn pinch_points(&self) -> Option<[PhysicalPosition; 2]> { + match self.fingers.as_slice() { + [(_, a), (_, b), ..] => Some([*a, *b]), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use visula::winit::event::DeviceId; + + fn touch(id: u64, phase: TouchPhase, x: f64, y: f64) -> Touch { + Touch { + device_id: DeviceId::dummy(), + phase, + location: PhysicalPosition::new(x, y), + force: None, + id, + } + } + + fn at(x: f64, y: f64) -> PhysicalPosition { + PhysicalPosition::new(x, y) + } + + #[test] + fn one_finger_acts_like_the_left_mouse_button() { + let mut touches = Touches::default(); + assert_eq!( + touches.update(&touch(1, TouchPhase::Started, 10.0, 20.0)), + TouchGesture::Press(at(10.0, 20.0)) + ); + assert_eq!( + touches.update(&touch(1, TouchPhase::Moved, 15.0, 25.0)), + TouchGesture::Drag(at(15.0, 25.0)) + ); + assert_eq!( + touches.update(&touch(1, TouchPhase::Ended, 15.0, 25.0)), + TouchGesture::Release + ); + } + + #[test] + fn second_finger_turns_a_drag_into_a_pinch() { + let mut touches = Touches::default(); + touches.update(&touch(1, TouchPhase::Started, 10.0, 10.0)); + assert_eq!( + touches.update(&touch(2, TouchPhase::Started, 30.0, 10.0)), + TouchGesture::Release + ); + assert_eq!( + touches.update(&touch(2, TouchPhase::Moved, 50.0, 10.0)), + TouchGesture::Pinch { + previous: [at(10.0, 10.0), at(30.0, 10.0)], + current: [at(10.0, 10.0), at(50.0, 10.0)], + } + ); + } + + #[test] + fn finger_left_after_a_pinch_does_nothing() { + let mut touches = Touches::default(); + touches.update(&touch(1, TouchPhase::Started, 10.0, 10.0)); + touches.update(&touch(2, TouchPhase::Started, 30.0, 10.0)); + assert_eq!( + touches.update(&touch(1, TouchPhase::Ended, 10.0, 10.0)), + TouchGesture::None + ); + assert_eq!( + touches.update(&touch(2, TouchPhase::Moved, 60.0, 10.0)), + TouchGesture::None + ); + assert_eq!( + touches.update(&touch(2, TouchPhase::Ended, 60.0, 10.0)), + TouchGesture::None + ); + } + + #[test] + fn touch_after_a_pinch_presses_again() { + let mut touches = Touches::default(); + touches.update(&touch(1, TouchPhase::Started, 10.0, 10.0)); + touches.update(&touch(2, TouchPhase::Started, 30.0, 10.0)); + touches.update(&touch(1, TouchPhase::Ended, 10.0, 10.0)); + touches.update(&touch(2, TouchPhase::Ended, 30.0, 10.0)); + assert_eq!( + touches.update(&touch(3, TouchPhase::Started, 5.0, 5.0)), + TouchGesture::Press(at(5.0, 5.0)) + ); + } + + #[test] + fn third_finger_is_ignored() { + let mut touches = Touches::default(); + touches.update(&touch(1, TouchPhase::Started, 0.0, 0.0)); + touches.update(&touch(2, TouchPhase::Started, 10.0, 0.0)); + assert_eq!( + touches.update(&touch(3, TouchPhase::Started, 20.0, 0.0)), + TouchGesture::None + ); + assert_eq!( + touches.update(&touch(3, TouchPhase::Moved, 25.0, 0.0)), + TouchGesture::None + ); + } + + #[test] + fn unknown_fingers_are_ignored() { + let mut touches = Touches::default(); + assert_eq!( + touches.update(&touch(7, TouchPhase::Moved, 1.0, 1.0)), + TouchGesture::None + ); + assert_eq!( + touches.update(&touch(7, TouchPhase::Ended, 1.0, 1.0)), + TouchGesture::None + ); + } + + #[test] + fn cancelled_touch_releases() { + let mut touches = Touches::default(); + touches.update(&touch(1, TouchPhase::Started, 0.0, 0.0)); + assert_eq!( + touches.update(&touch(1, TouchPhase::Cancelled, 0.0, 0.0)), + TouchGesture::Release + ); + } +} From 4140ad2b37a88f25239764110438a1cc014635fe Mon Sep 17 00:00:00 2001 From: Svenn-Arne Dragly Date: Thu, 22 Oct 2026 14:38:01 +0200 Subject: [PATCH 2/2] Only pan the camera when editing is off --- neuronify-core/src/app.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/neuronify-core/src/app.rs b/neuronify-core/src/app.rs index b415dae7..adb6b31a 100644 --- a/neuronify-core/src/app.rs +++ b/neuronify-core/src/app.rs @@ -100,6 +100,12 @@ fn ground_position(application: &visula::Application, position: PhysicalPosition ray_origin + t * ray_world } +fn pan(application: &mut visula::Application, offset: Vec3) { + let camera = &mut application.camera_controller; + camera.target_transform.center -= offset; + camera.current_transform.center = camera.target_transform.center; +} + fn gui_takes_press(application: &visula::Application, position: PhysicalPosition) -> bool { let context = application.egui_renderer.state.egui_ctx(); let point = egui::pos2(position.x as f32, position.y as f32) / context.pixels_per_point(); @@ -312,6 +318,14 @@ impl Neuronify { }; let mouse_position = ground_position(application, mouse_physical_position); + if !self.edit_enabled { + match *move_origin { + Some(origin) => pan(application, mouse_position - origin), + None => *move_origin = Some(mouse_position), + } + return; + } + let minimum_distance = match tool { Tool::Axon => MIN_CREATION_DISTANCE_AXON, _ => MIN_CREATION_DISTANCE_DEFAULT, @@ -676,13 +690,7 @@ impl Neuronify { } } else { match *move_origin { - Some(origin) => { - let center = mouse_position - origin; - application.camera_controller.target_transform.center -= - Vec3::new(center.x, center.y, center.z); - application.camera_controller.current_transform.center = - application.camera_controller.target_transform.center; - } + Some(origin) => pan(application, mouse_position - origin), None => { let voltmeter_bounds: Vec<_> = world .query::<(&Voltmeter, &Position)>()