Skip to main content

bimifc_bevy/
camera.rs

1//! Camera system with orbit, pan, and zoom controls
2//!
3//! Provides a flexible camera controller similar to the TypeScript version.
4
5#[cfg(target_arch = "wasm32")]
6use crate::storage::save_camera;
7use crate::storage::CameraStorage;
8use bevy::ecs::message::MessageReader;
9use bevy::input::mouse::{MouseMotion, MouseWheel};
10use bevy::input::touch::Touches;
11use bevy::prelude::*;
12
13/// System set for camera input (for ordering)
14#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
15pub struct CameraInputSet;
16
17/// Camera controller plugin
18pub struct CameraPlugin;
19
20impl Plugin for CameraPlugin {
21    fn build(&self, app: &mut App) {
22        app.init_resource::<CameraController>()
23            .add_systems(Startup, setup_camera)
24            .add_systems(
25                Update,
26                (
27                    poll_camera_commands_system,
28                    camera_input_system,
29                    camera_touch_system,
30                    camera_update_system,
31                    camera_keyboard_system,
32                )
33                    .chain()
34                    .in_set(CameraInputSet),
35            );
36    }
37}
38
39impl CameraPlugin {
40    /// Get the system set for camera input (for ordering picking after camera)
41    pub fn input_system_set() -> CameraInputSet {
42        CameraInputSet
43    }
44}
45
46/// Camera operating mode
47#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
48pub enum CameraMode {
49    #[default]
50    Orbit,
51    Pan,
52    Walk,
53}
54
55/// Camera controller resource
56#[derive(Resource)]
57pub struct CameraController {
58    /// Current mode
59    pub mode: CameraMode,
60    /// Target point to orbit around
61    pub target: Vec3,
62    /// Distance from target
63    pub distance: f32,
64    /// Azimuth angle (horizontal rotation)
65    pub azimuth: f32,
66    /// Elevation angle (vertical rotation)
67    pub elevation: f32,
68    /// Damping factor for smooth movement (0.0 = instant, 1.0 = never moves)
69    pub damping: f32,
70    /// Velocity for inertia
71    pub velocity: Vec3,
72    /// Angular velocity for orbit inertia
73    pub angular_velocity: Vec2,
74    /// Whether camera is currently animating
75    pub is_animating: bool,
76    /// Animation target (for preset views)
77    pub animation_target: Option<CameraAnimationTarget>,
78    /// Field of view in degrees
79    pub fov: f32,
80    /// Near clipping plane
81    pub near: f32,
82    /// Far clipping plane
83    pub far: f32,
84    /// Walk mode speed
85    pub walk_speed: f32,
86    /// Orbit sensitivity
87    pub orbit_sensitivity: f32,
88    /// Pan sensitivity
89    pub pan_sensitivity: f32,
90    /// Zoom sensitivity
91    pub zoom_sensitivity: f32,
92    /// Is dragging (mouse down)
93    pub is_dragging: bool,
94    /// Last mouse position
95    pub last_mouse_pos: Vec2,
96    /// Mouse position when drag started (for click detection)
97    pub drag_start_pos: Vec2,
98    /// Did actual dragging occur (mouse moved significantly)?
99    pub did_drag: bool,
100    /// Was this a click (released without dragging)?
101    pub just_clicked: bool,
102    // Touch state
103    /// Number of active touches last frame
104    pub touch_count: usize,
105    /// Last single-touch position (for computing deltas)
106    pub last_touch_pos: Vec2,
107    /// Last distance between two fingers (for pinch zoom)
108    pub last_pinch_distance: f32,
109    /// Last center of two fingers (for pan)
110    pub last_two_touch_center: Vec2,
111    /// Is a single-finger drag active?
112    pub is_touch_dragging: bool,
113    /// Position where touch started (for tap detection)
114    pub touch_drag_start: Vec2,
115    /// Did touch move significantly (not a tap)?
116    pub touch_did_drag: bool,
117}
118
119impl Default for CameraController {
120    fn default() -> Self {
121        Self {
122            mode: CameraMode::Orbit,
123            target: Vec3::ZERO,
124            distance: 100.0,  // Start further back for IFC models (in mm)
125            azimuth: 0.785,   // 45 degrees
126            elevation: 0.615, // ~35 degrees (isometric)
127            damping: 0.92,
128            velocity: Vec3::ZERO,
129            angular_velocity: Vec2::ZERO,
130            is_animating: false,
131            animation_target: None,
132            fov: 45.0,
133            near: 0.05,        // 5cm near plane — good depth precision for buildings
134            far: 10000.0,      // 10km far plane for large IFC models
135            walk_speed: 500.0, // 0.5m per frame for walking in mm-scale
136            orbit_sensitivity: 0.005,
137            pan_sensitivity: 0.01,
138            zoom_sensitivity: 0.02,
139            is_dragging: false,
140            last_mouse_pos: Vec2::ZERO,
141            drag_start_pos: Vec2::ZERO,
142            did_drag: false,
143            just_clicked: false,
144            touch_count: 0,
145            last_touch_pos: Vec2::ZERO,
146            last_pinch_distance: 0.0,
147            last_two_touch_center: Vec2::ZERO,
148            is_touch_dragging: false,
149            touch_drag_start: Vec2::ZERO,
150            touch_did_drag: false,
151        }
152    }
153}
154
155impl CameraController {
156    /// Get camera position from spherical coordinates
157    pub fn get_position(&self) -> Vec3 {
158        let x = self.distance * self.elevation.cos() * self.azimuth.sin();
159        let y = self.distance * self.elevation.sin();
160        let z = self.distance * self.elevation.cos() * self.azimuth.cos();
161        self.target + Vec3::new(x, y, z)
162    }
163
164    /// Set preset view
165    pub fn set_preset_view(&mut self, azimuth: f32, elevation: f32) {
166        self.animation_target = Some(CameraAnimationTarget {
167            azimuth,
168            elevation,
169            distance: self.distance,
170            target: self.target,
171            duration: 0.5,
172            elapsed: 0.0,
173        });
174        self.is_animating = true;
175    }
176
177    /// Set home/isometric view
178    pub fn home(&mut self) {
179        self.set_preset_view(0.785, 0.615); // 45°, 35.264°
180    }
181
182    /// Fit all - zoom to show entire scene
183    pub fn fit_bounds(&mut self, min: Vec3, max: Vec3) {
184        let center = (min + max) * 0.5;
185        let size = max - min;
186        let diagonal = size.length();
187
188        // Calculate distance to fit the entire model
189        let fov_rad = self.fov.to_radians();
190        let distance = diagonal / (2.0 * (fov_rad / 2.0).tan());
191
192        self.animation_target = Some(CameraAnimationTarget {
193            azimuth: self.azimuth,
194            elevation: self.elevation,
195            distance: distance.max(1.0),
196            target: center,
197            duration: 0.5,
198            elapsed: 0.0,
199        });
200        self.is_animating = true;
201    }
202
203    /// Frame selection - zoom to specific bounds
204    pub fn frame(&mut self, min: Vec3, max: Vec3) {
205        self.fit_bounds(min, max);
206    }
207
208    /// Zoom in
209    pub fn zoom_in(&mut self) {
210        self.distance = (self.distance * 0.8).max(1.0);
211    }
212
213    /// Zoom out
214    pub fn zoom_out(&mut self) {
215        self.distance = (self.distance * 1.25).min(500000.0);
216    }
217
218    /// Convert to storage format
219    pub fn to_storage(&self) -> CameraStorage {
220        CameraStorage {
221            azimuth: self.azimuth,
222            elevation: self.elevation,
223            distance: self.distance,
224            target: [self.target.x, self.target.y, self.target.z],
225        }
226    }
227
228    /// Load from storage format
229    pub fn from_storage(&mut self, storage: &CameraStorage) {
230        self.azimuth = storage.azimuth;
231        self.elevation = storage.elevation;
232        self.distance = storage.distance;
233        self.target = Vec3::new(storage.target[0], storage.target[1], storage.target[2]);
234    }
235}
236
237/// Animation target for smooth camera transitions
238#[derive(Clone, Debug)]
239pub struct CameraAnimationTarget {
240    pub azimuth: f32,
241    pub elevation: f32,
242    pub distance: f32,
243    pub target: Vec3,
244    pub duration: f32,
245    pub elapsed: f32,
246}
247
248/// Marker component for the main camera
249#[derive(Component)]
250pub struct MainCamera;
251
252/// Marker for architectural lighting entities (directional lights)
253/// so they can be toggled by the photometric module.
254#[derive(Component)]
255pub struct ArchitecturalLight;
256
257/// System to poll for camera commands from Yew UI
258#[allow(unused_variables, unused_mut)]
259fn poll_camera_commands_system(
260    mut controller: ResMut<CameraController>,
261    scene_data: Res<crate::IfcSceneData>,
262) {
263    #[cfg(target_arch = "wasm32")]
264    {
265        if let Some(cmd) = crate::storage::load_camera_cmd() {
266            crate::storage::clear_camera_cmd();
267
268            match cmd.cmd.as_str() {
269                "home" => {
270                    controller.home();
271                }
272                "fit_all" => {
273                    if let Some(ref bounds) = scene_data.bounds {
274                        controller.fit_bounds(bounds.min, bounds.max);
275                    }
276                }
277                "set_mode" => {
278                    if let Some(mode) = cmd.mode {
279                        controller.mode = match mode.as_str() {
280                            "pan" => CameraMode::Pan,
281                            "walk" => CameraMode::Walk,
282                            _ => CameraMode::Orbit,
283                        };
284                    }
285                }
286                _ => {}
287            }
288        }
289    }
290}
291
292/// Setup the 3D camera
293fn setup_camera(mut commands: Commands, controller: Res<CameraController>) {
294    use bevy::core_pipeline::tonemapping::Tonemapping;
295    use bevy::render::view::Msaa;
296
297    let position = controller.get_position();
298
299    commands.spawn((
300        Camera3d::default(),
301        Transform::from_translation(position).looking_at(controller.target, Vec3::Y),
302        Projection::Perspective(PerspectiveProjection {
303            fov: controller.fov.to_radians(),
304            near: controller.near,
305            far: controller.far,
306            ..default()
307        }),
308        MainCamera,
309        // Enable 4x MSAA for smoother edges
310        Msaa::Sample4,
311        // AgX tonemapping for natural, filmic look
312        Tonemapping::AgX,
313        // Single cluster: all lights evaluated per fragment — best for stadium
314        // floodlights where most lights illuminate the same area (the pitch).
315        bevy::light::cluster::ClusterConfig::Single,
316    ));
317
318    // Ambient light - enough to see into shadows but not wash out
319    commands.spawn(AmbientLight {
320        color: Color::srgb(0.9, 0.92, 1.0), // Slightly cool ambient
321        brightness: 150.0,
322        affects_lightmapped_meshes: true,
323    });
324
325    // Key directional light - main light from top-right-front
326    commands.spawn((
327        DirectionalLight {
328            color: Color::srgb(1.0, 0.98, 0.95), // Warm sunlight
329            illuminance: 30000.0,
330            shadows_enabled: false,
331            affects_lightmapped_mesh_diffuse: true,
332            ..default()
333        },
334        Transform::from_xyz(0.5, 1.0, 0.3).looking_at(Vec3::ZERO, Vec3::Y),
335        ArchitecturalLight,
336    ));
337
338    // Fill light from opposite side - cooler, softer
339    commands.spawn((
340        DirectionalLight {
341            color: Color::srgb(0.8, 0.88, 1.0), // Cool sky fill
342            illuminance: 12000.0,
343            shadows_enabled: false,
344            affects_lightmapped_mesh_diffuse: true,
345            ..default()
346        },
347        Transform::from_xyz(-0.5, 0.3, -0.5).looking_at(Vec3::ZERO, Vec3::Y),
348        ArchitecturalLight,
349    ));
350
351    // Rim/back light for edge definition
352    commands.spawn((
353        DirectionalLight {
354            color: Color::srgb(0.95, 0.95, 1.0),
355            illuminance: 8000.0,
356            shadows_enabled: false,
357            affects_lightmapped_mesh_diffuse: true,
358            ..default()
359        },
360        Transform::from_xyz(-0.3, 0.8, -0.8).looking_at(Vec3::ZERO, Vec3::Y),
361        ArchitecturalLight,
362    ));
363
364    // Bottom fill - subtle uplight to reduce dark undersides
365    commands.spawn((
366        DirectionalLight {
367            color: Color::srgb(0.7, 0.75, 0.85),
368            illuminance: 3000.0,
369            shadows_enabled: false,
370            affects_lightmapped_mesh_diffuse: true,
371            ..default()
372        },
373        Transform::from_xyz(0.0, -1.0, 0.0).looking_at(Vec3::ZERO, Vec3::Y),
374        ArchitecturalLight,
375    ));
376}
377
378/// Handle mouse input for camera control
379#[allow(unused_variables)]
380fn camera_input_system(
381    mouse_button: Res<ButtonInput<MouseButton>>,
382    mut mouse_motion: MessageReader<MouseMotion>,
383    mut mouse_wheel: MessageReader<MouseWheel>,
384    mut controller: ResMut<CameraController>,
385    windows: Query<&Window>,
386    measure_state: Res<crate::picking::MeasurementState>,
387    // Check if mouse is over any UI element with Interaction (only when bevy-ui feature is enabled)
388    #[cfg(feature = "bevy-ui")] ui_interactions: Query<&Interaction, With<Node>>,
389) {
390    let Ok(window) = windows.single() else { return };
391
392    // Check if mouse is over any UI element (hovered or pressed)
393    #[cfg(feature = "bevy-ui")]
394    let mouse_over_ui = ui_interactions
395        .iter()
396        .any(|interaction| matches!(interaction, Interaction::Hovered | Interaction::Pressed));
397    #[cfg(not(feature = "bevy-ui"))]
398    let mouse_over_ui = false;
399
400    // Check if measure tool is active — don't start camera drag for left-click
401    let is_measure = measure_state.active;
402
403    // Handle mouse button state - only start drag if not over UI and not measuring
404    if mouse_button.just_pressed(MouseButton::Left) && !mouse_over_ui && !is_measure {
405        controller.is_dragging = true;
406        controller.did_drag = false;
407        controller.just_clicked = false; // Reset on press
408        if let Some(pos) = window.cursor_position() {
409            controller.last_mouse_pos = pos;
410            controller.drag_start_pos = pos;
411        }
412    }
413    if mouse_button.just_released(MouseButton::Left) {
414        // Check if this was a click (no significant drag)
415        if !controller.did_drag {
416            controller.just_clicked = true;
417        }
418        controller.is_dragging = false;
419    }
420    // In measure mode, treat press as click (for immediate feedback)
421    if is_measure && mouse_button.just_pressed(MouseButton::Left) && !mouse_over_ui {
422        controller.just_clicked = true;
423        if let Some(pos) = window.cursor_position() {
424            controller.drag_start_pos = pos;
425        }
426    }
427
428    // Handle mouse motion
429    if controller.is_dragging {
430        for ev in mouse_motion.read() {
431            // Mark as drag if mouse moved significantly (more than 3 pixels)
432            if ev.delta.length() > 3.0 {
433                controller.did_drag = true;
434            }
435
436            match controller.mode {
437                CameraMode::Orbit => {
438                    controller.azimuth -= ev.delta.x * controller.orbit_sensitivity;
439                    controller.elevation -= ev.delta.y * controller.orbit_sensitivity;
440                    // Clamp elevation to avoid gimbal lock
441                    controller.elevation = controller.elevation.clamp(-1.5, 1.5);
442                    // Store angular velocity for inertia
443                    controller.angular_velocity = ev.delta * controller.orbit_sensitivity;
444                }
445                CameraMode::Pan => {
446                    // Calculate pan in camera space
447                    let right = Vec3::new(controller.azimuth.cos(), 0.0, -controller.azimuth.sin());
448                    let up = Vec3::Y;
449                    let pan = right
450                        * ev.delta.x
451                        * controller.pan_sensitivity
452                        * controller.distance
453                        * 0.01
454                        - up * ev.delta.y * controller.pan_sensitivity * controller.distance * 0.01;
455                    controller.target += pan;
456                }
457                CameraMode::Walk => {
458                    // First-person look
459                    controller.azimuth -= ev.delta.x * controller.orbit_sensitivity * 0.5;
460                    controller.elevation -= ev.delta.y * controller.orbit_sensitivity * 0.5;
461                    controller.elevation = controller.elevation.clamp(-1.5, 1.5);
462                }
463            }
464        }
465    } else {
466        // Apply damping to angular velocity when not dragging
467        let damping = controller.damping;
468        controller.angular_velocity *= damping;
469        if controller.angular_velocity.length() > 0.0001 {
470            controller.azimuth -= controller.angular_velocity.x;
471            controller.elevation -= controller.angular_velocity.y;
472            controller.elevation = controller.elevation.clamp(-1.5, 1.5);
473        }
474    }
475
476    // Handle mouse wheel for zoom - only when NOT over UI
477    if !mouse_over_ui {
478        for ev in mouse_wheel.read() {
479            let zoom_delta = ev.y * controller.zoom_sensitivity;
480            controller.distance = (controller.distance * (1.0 - zoom_delta)).clamp(1.0, 500000.0);
481        }
482    }
483}
484
485/// Handle touch input for camera control (mobile/tablet)
486///
487/// Gesture mapping:
488/// - 1 finger drag → orbit
489/// - 2 finger drag → pan
490/// - 2 finger pinch → zoom
491/// - Tap (no drag) → select entity
492fn camera_touch_system(touches: Res<Touches>, mut controller: ResMut<CameraController>) {
493    let pressed: Vec<Vec2> = touches.iter().map(|t| t.position()).collect();
494    let count = pressed.len();
495    let prev_count = controller.touch_count;
496
497    // === Single touch: orbit / tap ===
498    if count == 1 {
499        let pos = pressed[0];
500
501        if prev_count == 0 {
502            // Touch just started
503            controller.is_touch_dragging = true;
504            controller.touch_did_drag = false;
505            controller.last_touch_pos = pos;
506            controller.touch_drag_start = pos;
507        } else if controller.is_touch_dragging && prev_count == 1 {
508            // Continued single-finger drag → orbit
509            let delta = pos - controller.last_touch_pos;
510
511            if delta.length() > 2.0 {
512                controller.touch_did_drag = true;
513            }
514
515            if controller.touch_did_drag {
516                // Apply orbit (same math as mouse)
517                controller.azimuth -= delta.x * controller.orbit_sensitivity;
518                controller.elevation -= delta.y * controller.orbit_sensitivity;
519                controller.elevation = controller.elevation.clamp(-1.5, 1.5);
520                controller.angular_velocity = delta * controller.orbit_sensitivity;
521            }
522
523            controller.last_touch_pos = pos;
524        }
525    }
526
527    // === Two touches: pan + pinch zoom ===
528    if count == 2 {
529        let center = (pressed[0] + pressed[1]) * 0.5;
530        let distance = (pressed[0] - pressed[1]).length();
531
532        if prev_count < 2 {
533            // Just transitioned to two fingers — record baseline
534            controller.last_two_touch_center = center;
535            controller.last_pinch_distance = distance;
536            // Cancel single-finger orbit
537            controller.is_touch_dragging = false;
538            controller.touch_did_drag = true; // prevent tap
539        } else {
540            // Pan: delta of center point
541            let center_delta = center - controller.last_two_touch_center;
542            let right = Vec3::new(controller.azimuth.cos(), 0.0, -controller.azimuth.sin());
543            let up = Vec3::Y;
544            let pan =
545                right * center_delta.x * controller.pan_sensitivity * controller.distance * 0.01
546                    - up * center_delta.y * controller.pan_sensitivity * controller.distance * 0.01;
547            controller.target += pan;
548
549            // Pinch zoom: ratio of finger distances
550            if controller.last_pinch_distance > 10.0 {
551                let zoom_ratio = distance / controller.last_pinch_distance;
552                controller.distance = (controller.distance / zoom_ratio).clamp(1.0, 500000.0);
553            }
554
555            controller.last_two_touch_center = center;
556            controller.last_pinch_distance = distance;
557        }
558    }
559
560    // === Touch released: detect tap ===
561    if count == 0 && prev_count > 0 {
562        if controller.is_touch_dragging && !controller.touch_did_drag {
563            // This was a tap — trigger picking
564            controller.just_clicked = true;
565            controller.drag_start_pos = controller.touch_drag_start;
566        }
567        controller.is_touch_dragging = false;
568    }
569
570    controller.touch_count = count;
571}
572
573/// Handle keyboard input for camera control
574fn camera_keyboard_system(
575    keyboard: Res<ButtonInput<KeyCode>>,
576    mut controller: ResMut<CameraController>,
577    time: Res<Time>,
578) {
579    let dt = time.delta_secs();
580
581    // Walk mode movement (WASD)
582    if controller.mode == CameraMode::Walk {
583        let forward = Vec3::new(
584            -controller.azimuth.sin() * controller.elevation.cos(),
585            controller.elevation.sin(),
586            -controller.azimuth.cos() * controller.elevation.cos(),
587        )
588        .normalize();
589        let right = Vec3::new(controller.azimuth.cos(), 0.0, -controller.azimuth.sin());
590
591        let mut movement = Vec3::ZERO;
592
593        if keyboard.pressed(KeyCode::KeyW) || keyboard.pressed(KeyCode::ArrowUp) {
594            movement += forward;
595        }
596        if keyboard.pressed(KeyCode::KeyS) || keyboard.pressed(KeyCode::ArrowDown) {
597            movement -= forward;
598        }
599        if keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::ArrowLeft) {
600            movement -= right;
601        }
602        if keyboard.pressed(KeyCode::KeyD) || keyboard.pressed(KeyCode::ArrowRight) {
603            movement += right;
604        }
605        if keyboard.pressed(KeyCode::KeyQ) {
606            movement -= Vec3::Y;
607        }
608        if keyboard.pressed(KeyCode::KeyE) {
609            movement += Vec3::Y;
610        }
611
612        if movement.length() > 0.0 {
613            let walk_speed = controller.walk_speed;
614            controller.target += movement.normalize() * walk_speed * dt;
615        }
616    }
617
618    // Preset views (number keys)
619    if keyboard.just_pressed(KeyCode::Digit1) {
620        controller.set_preset_view(0.0, 0.0); // Front
621    }
622    if keyboard.just_pressed(KeyCode::Digit2) {
623        controller.set_preset_view(std::f32::consts::PI, 0.0); // Back
624    }
625    if keyboard.just_pressed(KeyCode::Digit3) {
626        controller.set_preset_view(-std::f32::consts::FRAC_PI_2, 0.0); // Left
627    }
628    if keyboard.just_pressed(KeyCode::Digit4) {
629        controller.set_preset_view(std::f32::consts::FRAC_PI_2, 0.0); // Right
630    }
631    if keyboard.just_pressed(KeyCode::Digit5) {
632        controller.set_preset_view(0.0, std::f32::consts::FRAC_PI_2 - 0.001); // Top
633    }
634    if keyboard.just_pressed(KeyCode::Digit6) {
635        controller.set_preset_view(0.0, -std::f32::consts::FRAC_PI_2 + 0.001); // Bottom
636    }
637    if keyboard.just_pressed(KeyCode::KeyH) {
638        controller.home(); // Isometric
639    }
640}
641
642/// Update camera transform
643fn camera_update_system(
644    mut controller: ResMut<CameraController>,
645    mut camera: Query<&mut Transform, With<MainCamera>>,
646    time: Res<Time>,
647) {
648    let dt = time.delta_secs();
649
650    // Handle animation
651    if controller.animation_target.is_some() {
652        // Extract animation target data to avoid borrow conflicts
653        let animation_data = {
654            let target = controller.animation_target.as_mut().unwrap();
655            target.elapsed += dt;
656            let t = (target.elapsed / target.duration).min(1.0);
657            // Ease out cubic
658            let t = 1.0 - (1.0 - t).powi(3);
659            let completed = target.elapsed >= target.duration;
660            (
661                target.azimuth,
662                target.elevation,
663                target.distance,
664                target.target,
665                t,
666                completed,
667            )
668        };
669
670        let (target_azimuth, target_elevation, target_distance, target_pos, t, completed) =
671            animation_data;
672
673        controller.azimuth = lerp(controller.azimuth, target_azimuth, t);
674        controller.elevation = lerp(controller.elevation, target_elevation, t);
675        controller.distance = lerp(controller.distance, target_distance, t);
676        controller.target = controller.target.lerp(target_pos, t);
677
678        if completed {
679            controller.animation_target = None;
680            controller.is_animating = false;
681        }
682    }
683
684    // Update camera transform
685    if let Ok(mut transform) = camera.single_mut() {
686        let position = controller.get_position();
687
688        // Apply damping for smooth movement
689        transform.translation = transform
690            .translation
691            .lerp(position, 1.0 - controller.damping.powi(2));
692        transform.look_at(controller.target, Vec3::Y);
693    }
694
695    // Save camera state periodically (WASM)
696    #[cfg(target_arch = "wasm32")]
697    {
698        // Only save occasionally to avoid flooding localStorage
699        static mut SAVE_COUNTER: u32 = 0;
700        unsafe {
701            SAVE_COUNTER += 1;
702            if SAVE_COUNTER % 30 == 0 {
703                save_camera(&controller.to_storage());
704            }
705        }
706    }
707}
708
709/// Linear interpolation
710fn lerp(a: f32, b: f32, t: f32) -> f32 {
711    a + (b - a) * t
712}