Skip to main content

eulumdat_bevy/viewer/
camera.rs

1//! First-person camera controller for the viewer.
2//!
3//! Supports both desktop (mouse + keyboard) and touch devices (iPad/iPhone):
4//! - Desktop: Right-click + drag to look, WASD/Arrows to move, Q/E up/down, scroll wheel to zoom
5//! - Touch: Single finger drag to look, two finger pinch to zoom, two finger drag to pan
6
7use bevy::input::mouse::{MouseMotion, MouseWheel};
8use bevy::input::touch::TouchPhase;
9use bevy::prelude::*;
10
11/// Plugin for first-person camera controls.
12pub struct CameraPlugin;
13
14impl Plugin for CameraPlugin {
15    fn build(&self, app: &mut App) {
16        app.init_resource::<TouchState>()
17            .add_systems(Startup, spawn_camera)
18            .add_systems(
19                Update,
20                (
21                    camera_look,
22                    camera_move,
23                    camera_zoom,
24                    camera_reset,
25                    touch_camera_control,
26                ),
27            );
28    }
29}
30
31/// Track touch state for gesture recognition.
32#[derive(Resource, Default)]
33struct TouchState {
34    /// Active touch points (id -> position)
35    touches: Vec<(u64, Vec2)>,
36    /// Previous frame touch positions for delta calculation
37    prev_touches: Vec<(u64, Vec2)>,
38    /// Previous distance between two fingers (for pinch zoom)
39    prev_pinch_distance: Option<f32>,
40    /// Previous center of two fingers (for two-finger pan)
41    prev_two_finger_center: Option<Vec2>,
42}
43
44/// Default camera position and look target.
45/// Position: standing on sidewalk looking at the lamp/scene center
46const DEFAULT_CAM_POS: Vec3 = Vec3::new(1.0, 1.7, 5.0); // Sidewalk, eye height ~1.7m
47const DEFAULT_LOOK_AT: Vec3 = Vec3::new(5.0, 4.0, 15.0); // Looking at lamp area
48
49/// First-person camera component.
50#[derive(Component)]
51pub struct FirstPersonCamera {
52    /// Movement speed in meters per second
53    pub speed: f32,
54    /// Mouse look sensitivity
55    pub sensitivity: f32,
56    /// Current pitch angle (up/down)
57    pub pitch: f32,
58    /// Current yaw angle (left/right)
59    pub yaw: f32,
60}
61
62impl Default for FirstPersonCamera {
63    fn default() -> Self {
64        Self {
65            speed: 3.0,
66            sensitivity: 0.003,
67            pitch: 0.0,
68            yaw: 0.8, // ~45 degrees, looking into the room
69        }
70    }
71}
72
73/// Calculate yaw and pitch from a direction vector.
74fn direction_to_yaw_pitch(dir: Vec3) -> (f32, f32) {
75    let yaw = dir.z.atan2(dir.x) - std::f32::consts::FRAC_PI_2;
76    let pitch = (-dir.y).asin();
77    (yaw, pitch)
78}
79
80/// Get default yaw/pitch for looking from DEFAULT_CAM_POS to DEFAULT_LOOK_AT.
81fn default_yaw_pitch() -> (f32, f32) {
82    let dir = (DEFAULT_LOOK_AT - DEFAULT_CAM_POS).normalize();
83    direction_to_yaw_pitch(dir)
84}
85
86fn spawn_camera(mut commands: Commands) {
87    let (yaw, pitch) = default_yaw_pitch();
88    commands.spawn((
89        Camera3d::default(),
90        Transform::from_translation(DEFAULT_CAM_POS).with_rotation(Quat::from_euler(
91            EulerRot::YXZ,
92            yaw,
93            pitch,
94            0.0,
95        )),
96        FirstPersonCamera {
97            yaw,
98            pitch,
99            ..default()
100        },
101    ));
102}
103
104/// Reset camera with R or Home key.
105fn camera_reset(
106    mut query: Query<(&mut Transform, &mut FirstPersonCamera)>,
107    keyboard: Res<ButtonInput<KeyCode>>,
108) {
109    if keyboard.just_pressed(KeyCode::KeyR) || keyboard.just_pressed(KeyCode::Home) {
110        let (yaw, pitch) = default_yaw_pitch();
111        for (mut transform, mut camera) in query.iter_mut() {
112            transform.translation = DEFAULT_CAM_POS;
113            camera.yaw = yaw;
114            camera.pitch = pitch;
115            transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, 0.0);
116        }
117    }
118}
119
120/// Mouse look system (right-click + drag).
121fn camera_look(
122    mut query: Query<(&mut Transform, &mut FirstPersonCamera)>,
123    mut mouse_motion: MessageReader<MouseMotion>,
124    mouse_button: Res<ButtonInput<MouseButton>>,
125) {
126    // Only look when right mouse button is held
127    if !mouse_button.pressed(MouseButton::Right) {
128        mouse_motion.clear();
129        return;
130    }
131
132    let mut delta = Vec2::ZERO;
133    for event in mouse_motion.read() {
134        delta += event.delta;
135    }
136
137    if delta == Vec2::ZERO {
138        return;
139    }
140
141    for (mut transform, mut camera) in query.iter_mut() {
142        camera.yaw -= delta.x * camera.sensitivity;
143        camera.pitch -= delta.y * camera.sensitivity;
144        camera.pitch = camera.pitch.clamp(-1.5, 1.5);
145
146        transform.rotation = Quat::from_euler(EulerRot::YXZ, camera.yaw, camera.pitch, 0.0);
147    }
148}
149
150/// Keyboard movement system (WASD/Arrows, Q/E for up/down).
151fn camera_move(
152    mut query: Query<(&mut Transform, &FirstPersonCamera)>,
153    keyboard: Res<ButtonInput<KeyCode>>,
154    time: Res<Time>,
155) {
156    for (mut transform, camera) in query.iter_mut() {
157        let mut direction = Vec3::ZERO;
158
159        // Get forward/right vectors (ignore Y for movement)
160        let forward = transform.forward();
161        let forward_flat = Vec3::new(forward.x, 0.0, forward.z).normalize_or_zero();
162        let right = transform.right();
163        let right_flat = Vec3::new(right.x, 0.0, right.z).normalize_or_zero();
164
165        // WASD movement
166        if keyboard.pressed(KeyCode::KeyW) || keyboard.pressed(KeyCode::ArrowUp) {
167            direction += forward_flat;
168        }
169        if keyboard.pressed(KeyCode::KeyS) || keyboard.pressed(KeyCode::ArrowDown) {
170            direction -= forward_flat;
171        }
172        if keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::ArrowLeft) {
173            direction -= right_flat;
174        }
175        if keyboard.pressed(KeyCode::KeyD) || keyboard.pressed(KeyCode::ArrowRight) {
176            direction += right_flat;
177        }
178
179        // Q/E for up/down
180        if keyboard.pressed(KeyCode::KeyQ) {
181            direction += Vec3::Y;
182        }
183        if keyboard.pressed(KeyCode::KeyE) {
184            direction -= Vec3::Y;
185        }
186
187        // Apply movement
188        if direction != Vec3::ZERO {
189            direction = direction.normalize();
190            transform.translation += direction * camera.speed * time.delta_secs();
191        }
192    }
193}
194
195/// Mouse scroll wheel zoom (move forward/backward along view direction).
196fn camera_zoom(
197    mut query: Query<(&mut Transform, &FirstPersonCamera)>,
198    mut scroll_events: MessageReader<MouseWheel>,
199) {
200    let mut scroll_delta: f32 = 0.0;
201    for event in scroll_events.read() {
202        scroll_delta += event.y;
203    }
204
205    if scroll_delta.abs() > 0.0 {
206        for (mut transform, camera) in query.iter_mut() {
207            let forward = transform.forward();
208            let zoom_speed = camera.speed * 0.5; // Slightly slower than movement
209            transform.translation += forward * scroll_delta * zoom_speed;
210        }
211    }
212}
213
214/// Touch camera control system for iPad/iPhone.
215///
216/// Gestures:
217/// - Single finger drag: Look around (rotate camera)
218/// - Two finger pinch: Zoom (move forward/backward)
219/// - Two finger drag: Pan (strafe left/right, up/down)
220fn touch_camera_control(
221    mut query: Query<(&mut Transform, &mut FirstPersonCamera)>,
222    mut touch_events: MessageReader<TouchInput>,
223    mut touch_state: ResMut<TouchState>,
224) {
225    // Update touch state from events
226    for event in touch_events.read() {
227        match event.phase {
228            TouchPhase::Started => {
229                // Add new touch
230                touch_state.touches.push((event.id, event.position));
231            }
232            TouchPhase::Moved => {
233                // Update existing touch position
234                if let Some(touch) = touch_state
235                    .touches
236                    .iter_mut()
237                    .find(|(id, _)| *id == event.id)
238                {
239                    touch.1 = event.position;
240                }
241            }
242            TouchPhase::Ended | TouchPhase::Canceled => {
243                // Remove touch
244                touch_state.touches.retain(|(id, _)| *id != event.id);
245                // Reset gesture state when touches change
246                touch_state.prev_pinch_distance = None;
247                touch_state.prev_two_finger_center = None;
248            }
249        }
250    }
251
252    // Process gestures based on number of active touches
253    let num_touches = touch_state.touches.len();
254
255    for (mut transform, mut camera) in query.iter_mut() {
256        if num_touches == 1 {
257            // Single finger: Look around
258            let current_pos = touch_state.touches[0].1;
259
260            // Find previous position for this touch
261            if let Some((_, prev_pos)) = touch_state
262                .prev_touches
263                .iter()
264                .find(|(id, _)| *id == touch_state.touches[0].0)
265            {
266                let delta = current_pos - *prev_pos;
267
268                // Apply rotation (scaled for touch - typically needs higher sensitivity)
269                let touch_sensitivity = camera.sensitivity * 0.5;
270                camera.yaw -= delta.x * touch_sensitivity;
271                camera.pitch -= delta.y * touch_sensitivity;
272                camera.pitch = camera.pitch.clamp(-1.5, 1.5);
273
274                transform.rotation = Quat::from_euler(EulerRot::YXZ, camera.yaw, camera.pitch, 0.0);
275            }
276
277            // Reset two-finger gesture state
278            touch_state.prev_pinch_distance = None;
279            touch_state.prev_two_finger_center = None;
280        } else if num_touches == 2 {
281            // Two fingers: Pinch zoom and pan
282            let pos1 = touch_state.touches[0].1;
283            let pos2 = touch_state.touches[1].1;
284
285            let current_distance = pos1.distance(pos2);
286            let current_center = (pos1 + pos2) / 2.0;
287
288            // Pinch zoom (move forward/backward)
289            if let Some(prev_distance) = touch_state.prev_pinch_distance {
290                let zoom_delta = current_distance - prev_distance;
291                let zoom_speed = 0.01; // Adjust for feel
292
293                let forward = transform.forward();
294                transform.translation += forward * zoom_delta * zoom_speed;
295            }
296
297            // Two-finger pan (strafe)
298            if let Some(prev_center) = touch_state.prev_two_finger_center {
299                let pan_delta = current_center - prev_center;
300                let pan_speed = 0.005 * camera.speed; // Adjust for feel
301
302                let right = transform.right();
303                let up = Vec3::Y;
304
305                // Pan horizontally and vertically
306                transform.translation -= right * pan_delta.x * pan_speed;
307                transform.translation += up * pan_delta.y * pan_speed;
308            }
309
310            touch_state.prev_pinch_distance = Some(current_distance);
311            touch_state.prev_two_finger_center = Some(current_center);
312        } else {
313            // No touches or 3+ fingers - reset gesture state
314            touch_state.prev_pinch_distance = None;
315            touch_state.prev_two_finger_center = None;
316        }
317    }
318
319    // Save current touches as previous for next frame
320    touch_state.prev_touches = touch_state.touches.clone();
321}