git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit953b9dd992590b941aab391cbd0b027f786c5349
parent9c22a8de00
authorLucas Galante <[email protected]>
date2026-07-06 10:37
Update designer app, project config, and window loop to use Viewport3D widget

 src/app.rs     | 412 +++++++++++++++------------------------------------------
 src/project.rs |  57 ++++----
 src/window.rs  |  13 +-
 3 files changed, 141 insertions(+), 341 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index be4dd61..dcc0648 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -35,7 +35,7 @@ use wayland_client::{
 };
 
 use wgpu::util::DeviceExt;
-use cce_ui::widget::{Breadcrumb, Canvas, MenuBar, Plate, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, ViewportBg, Element, GraphNode, Graph, Button, Checkbox, List, Label, Dropdown};
+use cce_ui::widget::{Breadcrumb, Canvas, MenuBar, Plate, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, Viewport3D, Element, GraphNode, Graph, Button, Checkbox, List, Label, Dropdown};
 use cce_ui::colors;
 use glyphon::{Attrs, Buffer, Cache, FontSystem, Metrics, Resolution, TextAtlas, TextRenderer, Viewport};
 use glam::{Mat4, Vec3};
@@ -784,20 +784,6 @@ pub struct State {
     pub backdrop_bind_group_layout: wgpu::BindGroupLayout,
     pub backdrop_bind_group: wgpu::BindGroup,
     pub window_info_buffer: wgpu::Buffer,
-    pub rotation_y: f32,
-    pub rotation_x: f32,
-    pub is_rotating_viewport: bool,
-    pub scroll_lock: u8, // 0 = None, 1 = Horizontal, 2 = Vertical
-    pub last_rotate_time: Instant,
-    pub rotate_accum_yaw: f32,
-    pub rotate_accum_pitch: f32,
-    pub rotate_velocity_yaw: f32,
-    pub rotate_velocity_pitch: f32,
-    pub show_grid: bool,
-    pub show_cube: bool,
-    pub show_origin: bool,
-    pub show_camera_pivot: bool,
-    pub viewport_bg_color: [f32; 3],
     pub node_color: [f32; 3],
     pub grid_color: [f32; 3],
     pub cell_color: [f32; 3],
@@ -886,19 +872,11 @@ pub struct State {
     pub scroll_accum_y: f32,
     pub last_spreadsheet_node_name: Option<String>,
     pub last_spreadsheet_node_params: Option<Vec<(String, String)>>,
-    pub viewport_zoom: f32,
-    pub is_zooming_viewport: bool,
-    pub last_zoom_time: Instant,
-    pub zoom_accum: f32,
-    pub zoom_velocity: f32,
     pub grid_thickness: f32,
     pub focused_pane: usize,
     pub graph_scroll_speed: f32,
     pub graph_inertial_scroll: bool,
     pub graph_scroll_friction: f32,
-    pub viewport_scroll_speed: f32,
-    pub viewport_inertial_scroll: bool,
-    pub viewport_scroll_friction: f32,
     pub last_config_read: Instant,
     pub circular_network_pane: bool,
     pub circular_network_layout: cce_ui::layout::CircularPaneLayout,
@@ -960,6 +938,20 @@ pub struct State {
 }
 
 impl State {
+    pub fn viewport(&self) -> &Viewport3D {
+        self.widgets[VIEWPORT_IDX]
+            .as_any()
+            .downcast_ref::<Viewport3D>()
+            .expect("VIEWPORT_IDX must be a Viewport3D")
+    }
+
+    pub fn viewport_mut(&mut self) -> &mut Viewport3D {
+        self.widgets[VIEWPORT_IDX]
+            .as_any_mut()
+            .downcast_mut::<Viewport3D>()
+            .expect("VIEWPORT_IDX must be a Viewport3D")
+    }
+
     pub fn find_widget_index(&self, child_ptr: *mut (dyn Element + 'static)) -> Option<usize> {
         let target_addr = child_ptr as *const ();
         self.widgets.iter().position(|w| {
@@ -1086,19 +1078,19 @@ impl State {
     pub fn save_settings(&mut self) {
         let settings = DesignSettings {
             viewport: ViewportSettings {
-                bg_color: self.viewport_bg_color,
+                bg_color: self.viewport().bg_color,
                 square: self.square_viewport,
-                show_camera_pivot_enabled: self.show_camera_pivot,
+                show_camera_pivot_enabled: self.viewport().show_camera_pivot,
                 camera_pivot_size: self.camera_pivot_size,
-                scroll_speed: Some(self.viewport_scroll_speed),
-                inertial_scroll: Some(self.viewport_inertial_scroll),
-                scroll_friction: Some(self.viewport_scroll_friction),
-                show_grid_enabled: self.show_grid,
-                show_cube_enabled: self.show_cube,
-                show_origin_enabled: self.show_origin,
+                scroll_speed: Some(self.viewport().scroll_speed),
+                inertial_scroll: Some(self.viewport().inertial_scroll),
+                scroll_friction: Some(self.viewport().scroll_friction),
+                show_grid_enabled: self.viewport().show_grid,
+                show_cube_enabled: self.viewport().show_cube,
+                show_origin_enabled: self.viewport().show_origin,
                 origin_size: self.origin_size,
                 grid_thickness: self.grid_thickness,
-                grid_color: self.grid_color,
+                grid_color: self.viewport().grid_color,
             },
             graph: GraphSettings {
                 grid_size_x: self.grid_size_x,
@@ -1139,7 +1131,7 @@ impl State {
     }
 
     pub fn update_viewport_bg_geometry(&mut self) {
-        let bg_color = cce_ui::colors::to_linear_rgb(self.viewport_bg_color);
+        let bg_color = cce_ui::colors::to_linear_rgb(self.viewport().bg_color);
         let bg_verts = [
             Vertex3D { position: [-1.0, -1.0, 9.99], color: bg_color }, // Bottom-left
             Vertex3D { position: [ 1.0, -1.0, 9.99], color: bg_color }, // Bottom-right
@@ -2793,7 +2785,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             Box::new(MenuBar::new(0.0, 0.0, 0.0, HEADER_H).with_title("Designer").with_label("Main Menu Bar").with_item("File", &["New Project", "Save", "Save As", "Exit"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out", "Reset Zoom", "Detach Circular Window", "Show Network Pane", "Show Viewport Pane", "Show Parameters Pane", "Show Spreadsheet Pane"]).with_item("Help", &["About"]).with_z_index(110).with_context_options(context_opts.clone(), 4)),
             Box::new(Graph::new()),
             Box::new(Splitter::new(SPLITTER_W)),
-            Box::new(ViewportBg::new()),
+            Box::new(Viewport3D::new()),
             Box::new(Splitter::new(SPLITTER_W)),
             Box::new(Plate::new(0.0, 0.0, 0.0, 0.0).with_color(colors::PARAM_BG).with_blur(true)),
             Box::new(ParametersBg::new()),
@@ -2807,6 +2799,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             Box::new(Spreadsheet::new()),
         ];
         
+        if let Some(viewport) = widgets[VIEWPORT_IDX].as_any_mut().downcast_mut::<Viewport3D>() {
+            viewport.show_grid = settings.viewport.show_grid_enabled;
+            viewport.show_cube = settings.viewport.show_cube_enabled;
+            viewport.show_origin = settings.viewport.show_origin_enabled;
+            viewport.show_camera_pivot = settings.viewport.show_camera_pivot_enabled;
+            viewport.bg_color = settings.viewport.bg_color;
+            viewport.grid_color = settings.viewport.grid_color;
+            viewport.active_camera = active_camera.clone();
+        }
+        
         let recent_files = Self::load_recent_files();
 
         let mut spreadsheet_menubar = MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("3: Spreadsheet").with_label("Spreadsheet Menu Bar").with_item("View", &["Close Pane"]).with_context_options(context_opts.clone(), 3);
@@ -2876,20 +2878,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             backdrop_bind_group_layout,
             backdrop_bind_group,
             window_info_buffer,
-            rotation_y: 0.0,
-            rotation_x: 0.0,
-            is_rotating_viewport: false,
-            scroll_lock: 0,
-            last_rotate_time: Instant::now(),
-            rotate_accum_yaw: 0.0,
-            rotate_accum_pitch: 0.0,
-            rotate_velocity_yaw: 0.0,
-            rotate_velocity_pitch: 0.0,
-            show_grid: settings.viewport.show_grid_enabled,
-            show_cube: settings.viewport.show_cube_enabled,
-            show_origin: settings.viewport.show_origin_enabled,
-            show_camera_pivot: settings.viewport.show_camera_pivot_enabled,
-            viewport_bg_color: settings.viewport.bg_color,
             node_color: {
                 let nc = cce_ui::color::graph_node_color();
                 [nc[0], nc[1], nc[2]]
@@ -2977,19 +2965,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             scroll_accum_y: 0.0,
             last_spreadsheet_node_name: None,
             last_spreadsheet_node_params: None,
-            viewport_zoom: 1.0,
-            is_zooming_viewport: false,
-            last_zoom_time: Instant::now(),
-            zoom_accum: 0.0,
-            zoom_velocity: 0.0,
             grid_thickness: settings.viewport.grid_thickness,
             focused_pane: LEFT_MENUBAR_IDX,
             graph_scroll_speed: settings.graph.scroll_speed.unwrap_or(1.0),
             graph_inertial_scroll: settings.graph.inertial_scroll.unwrap_or(true),
             graph_scroll_friction: settings.graph.scroll_friction.unwrap_or(0.90),
-            viewport_scroll_speed: settings.viewport.scroll_speed.unwrap_or(1.0),
-            viewport_inertial_scroll: settings.viewport.inertial_scroll.unwrap_or(true),
-            viewport_scroll_friction: settings.viewport.scroll_friction.unwrap_or(0.90),
             last_config_read: Instant::now(),
             circular_network_pane: is_detached_network,
             circular_network_layout: cce_ui::layout::CircularPaneLayout::new(250.0, 300.0, 180.0),
@@ -3085,10 +3065,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         state.sync_nodes();
         state.rebuild_scene_geometry();
         state.sync_grid_settings();
-        let sg = state.show_grid;
-        let sc = state.show_cube;
-        let so = state.show_origin;
-        let cp = state.show_camera_pivot;
+        let sg = state.viewport().show_grid;
+        let sc = state.viewport().show_cube;
+        let so = state.viewport().show_origin;
+        let cp = state.viewport().show_camera_pivot;
         let cnp = state.circular_network_pane;
         let dcn = state.detached_circular_network;
         let sn = state.show_network;
@@ -3185,9 +3165,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         self.graph_scroll_speed = settings.graph.scroll_speed.unwrap_or(speed);
         self.graph_inertial_scroll = settings.graph.inertial_scroll.unwrap_or(enabled);
         self.graph_scroll_friction = settings.graph.scroll_friction.unwrap_or(friction);
-        self.viewport_scroll_speed = settings.viewport.scroll_speed.unwrap_or(speed);
-        self.viewport_inertial_scroll = settings.viewport.inertial_scroll.unwrap_or(enabled);
-        self.viewport_scroll_friction = settings.viewport.scroll_friction.unwrap_or(friction);
+        self.viewport_mut().scroll_speed = settings.viewport.scroll_speed.unwrap_or(speed);
+        self.viewport_mut().inertial_scroll = settings.viewport.inertial_scroll.unwrap_or(enabled);
+        self.viewport_mut().scroll_friction = settings.viewport.scroll_friction.unwrap_or(friction);
     }
 
     pub fn update_graph_settings_from_config(&mut self) {
@@ -3763,26 +3743,26 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         let mut settings_changed = false;
         match action {
             Action::ToggleGrid => {
-                self.show_grid = !self.show_grid;
-                let val = self.show_grid;
+                let val = !self.viewport().show_grid;
+                self.viewport_mut().show_grid = val;
                 self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 0, val);
                 settings_changed = true;
             }
             Action::ToggleCube => {
-                self.show_cube = !self.show_cube;
-                let val = self.show_cube;
+                let val = !self.viewport().show_cube;
+                self.viewport_mut().show_cube = val;
                 self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 1, val);
                 settings_changed = true;
             }
             Action::ToggleOrigin => {
-                self.show_origin = !self.show_origin;
-                let val = self.show_origin;
+                let val = !self.viewport().show_origin;
+                self.viewport_mut().show_origin = val;
                 self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 2, val);
                 settings_changed = true;
             }
             Action::ToggleCameraPivot => {
-                self.show_camera_pivot = !self.show_camera_pivot;
-                let val = self.show_camera_pivot;
+                let val = !self.viewport().show_camera_pivot;
+                self.viewport_mut().show_camera_pivot = val;
                 self.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(2, 3, val);
                 settings_changed = true;
             }
@@ -4163,108 +4143,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                                 self.scroll_accum_x += dx;
                                 self.scroll_accum_y += dy;
                                 self.sync_grid_settings();
-                                true
-                            }
-                        }
-                    }
-                } else if !dialog_open && in_viewport {
-                    if self.modifiers.control_key() {
-                        match delta {
-                            MouseScrollDelta::LineDelta(_x, y) => {
-                                let dy = *y * 0.15 * self.viewport_scroll_speed;
-                                self.viewport_zoom *= (-dy).exp();
-                                self.viewport_zoom = self.viewport_zoom.clamp(0.05, 20.0);
-
-                                self.is_zooming_viewport = false;
-                                let dt_scroll = Instant::now().duration_since(self.last_frame).as_secs_f32().min(0.1);
-                                let vel_zoom = if dt_scroll > 1e-4 { dy / dt_scroll } else { dy * 60.0 };
-                                self.zoom_velocity = self.zoom_velocity * 0.4 + vel_zoom * 0.6;
-
-                                true
-                            }
-                            MouseScrollDelta::PixelDelta(pos) => {
-                                let dy = (pos.y as f32 / self.scale as f32) * 0.005 * self.viewport_scroll_speed;
-                                self.viewport_zoom *= (-dy).exp();
-                                self.viewport_zoom = self.viewport_zoom.clamp(0.05, 20.0);
-
-                                self.is_zooming_viewport = match phase {
-                                    TouchPhase::Started | TouchPhase::Moved => true,
-                                    TouchPhase::Ended | TouchPhase::Cancelled => false,
-                                };
-                                self.last_zoom_time = Instant::now();
-                                self.zoom_accum += dy;
-
-                                true
-                            }
-                        }
-                    } else {
-                        match delta {
-                            MouseScrollDelta::LineDelta(x, y) => {
-                                self.scroll_lock = 0;
-                                let dx = *x * 0.05 * self.viewport_scroll_speed;
-                                let dy = *y * 0.05 * self.viewport_scroll_speed;
-                                if self.active_camera != "Default Camera" {
-                                    self.update_active_camera_rotation(dx, -dy);
-                                } else {
-                                    self.rotation_y += dx;
-                                    self.rotation_x -= dy;
-                                }
-
-                                self.is_rotating_viewport = false;
-                                let dt_scroll = Instant::now().duration_since(self.last_frame).as_secs_f32().min(0.1);
-                                let vel_yaw = if dt_scroll > 1e-4 { dx / dt_scroll } else { dx * 60.0 };
-                                let vel_pitch = if dt_scroll > 1e-4 { -dy / dt_scroll } else { -dy * 60.0 };
-                                self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + vel_yaw * 0.6;
-                                self.rotate_velocity_pitch = self.rotate_velocity_pitch * 0.4 + vel_pitch * 0.6;
-
-                                true
-                            }
-                            MouseScrollDelta::PixelDelta(pos) => {
-                                let mut dx = (pos.x as f32 / self.scale as f32) * 0.005 * self.viewport_scroll_speed;
-                                let mut dy = (pos.y as f32 / self.scale as f32) * 0.005 * self.viewport_scroll_speed;
-
-                                if *phase == TouchPhase::Started {
-                                    self.scroll_lock = 0;
-                                    self.rotate_accum_yaw = 0.0;
-                                    self.rotate_accum_pitch = 0.0;
-                                }
-
-                                if self.scroll_lock == 0 {
-                                    self.rotate_accum_yaw += dx;
-                                    self.rotate_accum_pitch -= dy;
-                                    if self.rotate_accum_yaw.abs() > 0.002 || self.rotate_accum_pitch.abs() > 0.002 {
-                                        if self.rotate_accum_pitch.abs() > 1.2 * self.rotate_accum_yaw.abs() {
-                                            self.scroll_lock = 2; // lock vertical
-                                        } else if self.rotate_accum_yaw.abs() > 1.2 * self.rotate_accum_pitch.abs() {
-                                            self.scroll_lock = 1; // lock horizontal
-                                        }
-                                    }
-                                } else {
-                                    if self.scroll_lock == 1 {
-                                        dy = 0.0;
-                                        self.rotate_accum_yaw += dx;
-                                    } else {
-                                        dx = 0.0;
-                                        self.rotate_accum_pitch -= dy;
-                                    }
-                                }
-
-                                if self.active_camera != "Default Camera" {
-                                    self.update_active_camera_rotation(dx, -dy);
-                                } else {
-                                    self.rotation_y += dx;
-                                    self.rotation_x -= dy;
-                                }
-
-                                self.is_rotating_viewport = match phase {
-                                    TouchPhase::Started | TouchPhase::Moved => true,
-                                    TouchPhase::Ended | TouchPhase::Cancelled => {
-                                        self.scroll_lock = 0;
-                                        false
-                                    }
-                                };
-                                self.last_rotate_time = Instant::now();
-
                                 true
                             }
                         }
@@ -4414,16 +4292,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     self.scroll_accum_x = 0.0;
                     self.scroll_accum_y = 0.0;
 
-                    self.rotate_velocity_yaw = 0.0;
-                    self.rotate_velocity_pitch = 0.0;
-                    self.is_rotating_viewport = false;
-                    self.scroll_lock = 0;
-                    self.rotate_accum_yaw = 0.0;
-                    self.rotate_accum_pitch = 0.0;
-
-                    self.zoom_velocity = 0.0;
-                    self.is_zooming_viewport = false;
-                    self.zoom_accum = 0.0;
+                    self.viewport_mut().reset_velocity();
                 }
                 if self.node_palette_visible {
                     if *btn_state == ElementState::Pressed {
@@ -5055,16 +4924,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                                                 if self.active_camera != "Default Camera" {
                                                     self.update_active_camera_rotation_reset();
                                                 } else {
-                                                    self.rotation_y = 0.0;
-                                                    self.rotation_x = 0.0;
+                                                    self.viewport_mut().rotation_y = 0.0;
+                                                    self.viewport_mut().rotation_x = 0.0;
                                                 }
-                                                self.viewport_zoom = 1.0;
-                                                self.rotate_velocity_yaw = 0.0;
-                                                self.rotate_velocity_pitch = 0.0;
-                                                self.zoom_velocity = 0.0;
-                                                self.is_rotating_viewport = false;
-                                                self.is_zooming_viewport = false;
-                                                self.scroll_lock = 0;
+                                                self.viewport_mut().zoom = 1.0;
+                                                self.viewport_mut().reset_velocity();
                                                 changed = true;
                                             }
                                         }
@@ -5300,14 +5164,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                          self.skipped_row_h = settings.graph.skipped_row_h;
                          self.skipped_col_w = settings.graph.skipped_col_w;
                          self.grid_thickness = settings.viewport.grid_thickness;
-                         self.show_grid = settings.viewport.show_grid_enabled;
-                         self.show_cube = settings.viewport.show_cube_enabled;
-                         self.show_origin = settings.viewport.show_origin_enabled;
-                         self.show_camera_pivot = settings.viewport.show_camera_pivot_enabled;
-                         self.viewport_bg_color = settings.viewport.bg_color;
+                         self.viewport_mut().show_grid = settings.viewport.show_grid_enabled;
+                         self.viewport_mut().show_cube = settings.viewport.show_cube_enabled;
+                         self.viewport_mut().show_origin = settings.viewport.show_origin_enabled;
+                         self.viewport_mut().show_camera_pivot = settings.viewport.show_camera_pivot_enabled;
+                         self.viewport_mut().bg_color = settings.viewport.bg_color;
                          let node_c = cce_ui::color::graph_node_color();
                          self.node_color = [node_c[0], node_c[1], node_c[2]];
-                         self.grid_color = settings.viewport.grid_color;
+                         self.viewport_mut().grid_color = settings.viewport.grid_color;
                          self.origin_size = settings.viewport.origin_size;
                          self.camera_pivot_size = settings.viewport.camera_pivot_size;
                          self.cell_color = cce_ui::color::graph_cell_color();
@@ -5315,9 +5179,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                           if let Some(val) = settings.graph.scroll_speed { self.graph_scroll_speed = val; }
                           if let Some(val) = settings.graph.inertial_scroll { self.graph_inertial_scroll = val; }
                           if let Some(val) = settings.graph.scroll_friction { self.graph_scroll_friction = val; }
-                          if let Some(val) = settings.viewport.scroll_speed { self.viewport_scroll_speed = val; }
-                          if let Some(val) = settings.viewport.inertial_scroll { self.viewport_inertial_scroll = val; }
-                          if let Some(val) = settings.viewport.scroll_friction { self.viewport_scroll_friction = val; }
+                          if let Some(val) = settings.viewport.scroll_speed { self.viewport_mut().scroll_speed = val; }
+                          if let Some(val) = settings.viewport.inertial_scroll { self.viewport_mut().inertial_scroll = val; }
+                          if let Some(val) = settings.viewport.scroll_friction { self.viewport_mut().scroll_friction = val; }
 
                         colors::set_node_color([self.node_color[0], self.node_color[1], self.node_color[2], 1.0]);
 
@@ -5358,32 +5222,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.scroll_accum_y = 0.0;
         }
 
-        // Viewport rotation velocity tracking & timeout detection
-        if self.is_rotating_viewport {
-            if now.duration_since(self.last_rotate_time).as_secs_f32() > 0.05 {
-                self.is_rotating_viewport = false;
-                self.scroll_lock = 0;
-            } else if dt > 1e-5 {
-                let vel_yaw = self.rotate_accum_yaw / dt;
-                let vel_pitch = self.rotate_accum_pitch / dt;
-                self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + vel_yaw * 0.6;
-                self.rotate_velocity_pitch = self.rotate_velocity_pitch * 0.4 + vel_pitch * 0.6;
-            }
-            self.rotate_accum_yaw = 0.0;
-            self.rotate_accum_pitch = 0.0;
-        }
-
-        // Viewport zoom velocity tracking & timeout detection
-        if self.is_zooming_viewport {
-            if now.duration_since(self.last_zoom_time).as_secs_f32() > 0.05 {
-                self.is_zooming_viewport = false;
-            } else if dt > 1e-5 {
-                let vel_zoom = self.zoom_accum / dt;
-                self.zoom_velocity = self.zoom_velocity * 0.4 + vel_zoom * 0.6;
-            }
-            self.zoom_accum = 0.0;
-        }
-
         let mut tick_changed = false;
         let ctx = &mut self.ui_context;
         for w in &mut self.widgets {
@@ -5394,6 +5232,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         if cce_ui::widget::hover_animation::tick(dt) {
             tick_changed = true;
         }
+
+        let py = self.viewport().pending_yaw;
+        let pp = self.viewport().pending_pitch;
+        if py != 0.0 || pp != 0.0 {
+            if self.update_active_camera_rotation(py, pp) {
+                tick_changed = true;
+            }
+            self.viewport_mut().pending_yaw = 0.0;
+            self.viewport_mut().pending_pitch = 0.0;
+        }
+
         self.update_recent_files_layout();
 
         // Panning kinetic slide
@@ -5424,51 +5273,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             }
         }
 
-        // Viewport rotation kinetic slide
-        if !self.is_rotating_viewport && (self.rotate_velocity_yaw.abs() > 0.001 || self.rotate_velocity_pitch.abs() > 0.001) {
-            if !self.viewport_inertial_scroll {
-                self.rotate_velocity_yaw = 0.0;
-                self.rotate_velocity_pitch = 0.0;
-            } else {
-                let dx = self.rotate_velocity_yaw * dt;
-                let dy = self.rotate_velocity_pitch * dt;
-                if self.active_camera != "Default Camera" {
-                    self.update_active_camera_rotation(dx, dy);
-                } else {
-                    self.rotation_y += dx;
-                    self.rotation_x += dy;
-                }
-
-                // Apply dynamic friction decay
-                let decay = self.viewport_scroll_friction.powf(dt * 60.0);
-                self.rotate_velocity_yaw *= decay;
-                self.rotate_velocity_pitch *= decay;
 
-                if self.rotate_velocity_yaw.abs() < 0.01 { self.rotate_velocity_yaw = 0.0; }
-                if self.rotate_velocity_pitch.abs() < 0.01 { self.rotate_velocity_pitch = 0.0; }
-                tick_changed = true;
-            }
-        }
-
-        // Viewport zoom kinetic slide
-        if !self.is_zooming_viewport && self.zoom_velocity.abs() > 0.001 {
-            if !self.viewport_inertial_scroll {
-                self.zoom_velocity = 0.0;
-            } else {
-                let d_zoom = self.zoom_velocity * dt;
-                self.viewport_zoom *= (-d_zoom).exp();
-                self.viewport_zoom = self.viewport_zoom.clamp(0.05, 20.0);
-
-                // Apply dynamic friction decay
-                let decay = self.viewport_scroll_friction.powf(dt * 60.0);
-                self.zoom_velocity *= decay;
-
-                if self.zoom_velocity.abs() < 0.01 {
-                    self.zoom_velocity = 0.0;
-                }
-                tick_changed = true;
-            }
-        }
 
         if tick_changed {
             self.upload_vertices();
@@ -5556,9 +5361,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             }
 
             if cw > 0 && ch > 0 {
-                let aspect = cw as f32 / ch as f32;
 
-                let proj = Mat4::perspective_rh(0.9, aspect, 0.1, 100.0);
                 let mut camera_pos = Vec3::new(2.5, 1.8, 2.5);
                 let mut rx = 0.0f32;
                 let mut ry = 0.0f32;
@@ -5616,33 +5419,22 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     || self.last_viewport_camera_ry != ry
                     || self.last_viewport_camera_rz != rz
                     || self.last_viewport_pivot != pivot
-                    || self.last_viewport_zoom != self.viewport_zoom
-                    || self.last_viewport_rotation_x != self.rotation_x
-                    || self.last_viewport_rotation_y != self.rotation_y
-                    || self.last_viewport_bg_color != self.viewport_bg_color
-                    || self.last_viewport_show_grid != self.show_grid
-                    || self.last_viewport_show_cube != self.show_cube
-                    || self.last_viewport_show_origin != self.show_origin
-                    || self.last_viewport_show_camera_pivot != self.show_camera_pivot
+                    || self.last_viewport_zoom != self.viewport().zoom
+                    || self.last_viewport_rotation_x != self.viewport().rotation_x
+                    || self.last_viewport_rotation_y != self.viewport().rotation_y
+                    || self.last_viewport_bg_color != self.viewport().bg_color
+                    || self.last_viewport_show_grid != self.viewport().show_grid
+                    || self.last_viewport_show_cube != self.viewport().show_cube
+                    || self.last_viewport_show_origin != self.viewport().show_origin
+                    || self.last_viewport_show_camera_pivot != self.viewport().show_camera_pivot
                     || self.last_viewport_width != cw
                     || self.last_viewport_height != ch
                     || self.last_viewport_active_camera != self.active_camera
                     || self.last_viewport_show_viewport != self.show_viewport;
 
                 if viewport_changed {
-                    let base_offset = camera_pos - pivot;
-                    let distance = base_offset.length();
-                    let yaw0 = base_offset.x.atan2(base_offset.z);
-                    let pitch0 = (base_offset.y / distance.max(1e-5)).asin();
-
-                    let total_ry = ry.to_radians() + yaw0;
-                    let total_rx = rx.to_radians() + pitch0;
-
-                    let view_rot_pos = Mat4::from_rotation_y(total_ry) * Mat4::from_rotation_x(-total_rx);
-                    let camera_up = view_rot_pos.transform_vector3(Vec3::Y);
-                    let camera_world_pos = pivot + view_rot_pos.transform_vector3(Vec3::new(0.0, 0.0, distance) * self.viewport_zoom);
-                    let view_mat = Mat4::from_rotation_z(rz.to_radians()) * Mat4::look_at_rh(camera_world_pos, pivot, camera_up);
-                    let model = Mat4::from_rotation_y(self.rotation_y) * Mat4::from_rotation_x(self.rotation_x);
+                    let aspect = cw as f32 / ch as f32;
+                    let (proj, view_mat, model) = self.viewport().get_matrices(aspect, Some(camera_pos), Some(Vec3::new(rx, ry, rz)), Some(pivot));
                     let mvp = proj * view_mat * model;
                     let window_size = [self.physical_width as f32, self.physical_height as f32];
                     let window_radius = cce_ui::color::backplate_corner_radius() * self.scale as f32;
@@ -5666,9 +5458,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
                     let cam_angle_y = camera_pos.x.atan2(camera_pos.z);
                     let rot_angle = if self.active_camera != "Default Camera" {
-                        total_ry
+                        let base_offset = camera_pos - pivot;
+                        let yaw0 = base_offset.x.atan2(base_offset.z);
+                        ry.to_radians() + yaw0
                     } else {
-                        self.rotation_y + cam_angle_y
+                        self.viewport().rotation_y + cam_angle_y
                     };
                     let model_pivot = Mat4::from_translation(pivot) * Mat4::from_rotation_y(rot_angle);
                     let mvp_pivot = proj * view_mat * model_pivot;
@@ -5715,19 +5509,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     pass.set_vertex_buffer(0, self.vertex_buffer_viewport_bg.slice(..));
                     pass.draw(0..6, 0..1);
 
-                    if self.show_grid {
+                    if self.viewport().show_grid {
                         pass.set_bind_group(0, &self.bind_group_grid, &[]);
                         pass.set_vertex_buffer(0, self.vertex_buffer_grid.slice(..));
                         pass.draw(0..self.vertex_count_grid, 0..1);
                     }
 
-                    if self.show_origin {
+                    if self.viewport().show_origin {
                         pass.set_bind_group(0, &self.bind_group_3d, &[]);
                         pass.set_vertex_buffer(0, self.vertex_buffer_origin.slice(..));
                         pass.draw(0..self.vertex_count_origin, 0..1);
                     }
 
-                    if self.show_camera_pivot {
+                    if self.viewport().show_camera_pivot {
                         pass.set_bind_group(0, &self.bind_group_pivot, &[]);
                         pass.set_vertex_buffer(0, self.vertex_buffer_pivot.slice(..));
                         pass.draw(0..self.vertex_count_pivot, 0..1);
@@ -5735,7 +5529,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
                     pass.set_bind_group(0, &self.bind_group_3d, &[]);
 
-                    if self.show_cube {
+                    if self.viewport().show_cube {
                         pass.set_vertex_buffer(0, self.vertex_buffer_3d.slice(..));
                         pass.draw(0..self.vertex_count_3d, 0..1);
                     }
@@ -5751,14 +5545,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     self.last_viewport_camera_ry = ry;
                     self.last_viewport_camera_rz = rz;
                     self.last_viewport_pivot = pivot;
-                    self.last_viewport_zoom = self.viewport_zoom;
-                    self.last_viewport_rotation_x = self.rotation_x;
-                    self.last_viewport_rotation_y = self.rotation_y;
-                    self.last_viewport_bg_color = self.viewport_bg_color;
-                    self.last_viewport_show_grid = self.show_grid;
-                    self.last_viewport_show_cube = self.show_cube;
-                    self.last_viewport_show_origin = self.show_origin;
-                    self.last_viewport_show_camera_pivot = self.show_camera_pivot;
+                    self.last_viewport_zoom = self.viewport().zoom;
+                    self.last_viewport_rotation_x = self.viewport().rotation_x;
+                    self.last_viewport_rotation_y = self.viewport().rotation_y;
+                    self.last_viewport_bg_color = self.viewport().bg_color;
+                    self.last_viewport_show_grid = self.viewport().show_grid;
+                    self.last_viewport_show_cube = self.viewport().show_cube;
+                    self.last_viewport_show_origin = self.viewport().show_origin;
+                    self.last_viewport_show_camera_pivot = self.viewport().show_camera_pivot;
                     self.last_viewport_width = cw;
                     self.last_viewport_height = ch;
                     self.last_viewport_active_camera = self.active_camera.clone();
@@ -5773,7 +5567,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                         resolve_target: None,
                         ops: wgpu::Operations {
                             load: wgpu::LoadOp::Clear({
-                                let linear_bg = cce_ui::colors::to_linear_rgb(self.viewport_bg_color);
+                                let linear_bg = cce_ui::colors::to_linear_rgb(self.viewport().bg_color);
                                 wgpu::Color {
                                     r: linear_bg[0] as f64,
                                     g: linear_bg[1] as f64,
diff --git a/src/project.rs b/src/project.rs
index 8ef2af4..983c198 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -398,6 +398,13 @@ impl State {
         ensure_param(net_node, "Node Color B", "spinbox", &((self.node_color[2] * 255.0) as i32).to_string(), &[], Some(0.0), Some(255.0), Some(1.0));
 
         // 3. Viewport subnet
+        let vp_show_grid = self.viewport().show_grid;
+        let vp_show_cube = self.viewport().show_cube;
+        let vp_show_origin = self.viewport().show_origin;
+        let vp_show_camera_pivot = self.viewport().show_camera_pivot;
+        let vp_bg_color = self.viewport().bg_color;
+        let vp_grid_color = self.viewport().grid_color;
+
         let vp_node = find_or_create_subnet(&mut self.fs_root, "Viewport", "utility", (4.0, 0.0));
         vp_node.children.clear();
 
@@ -415,24 +422,24 @@ impl State {
         ensure_param(vp_node, "Square Aspect", "choice", if self.square_viewport { "true" } else { "false" }, &["false", "true"], None, None, None);
 
         ensure_param(vp_node, "Guides", "section", "", &[], None, None, None);
-        ensure_param(vp_node, "Show Grid", "choice", if self.show_grid { "true" } else { "false" }, &["false", "true"], None, None, None);
-        ensure_param(vp_node, "Cube", "choice", if self.show_cube { "true" } else { "false" }, &["false", "true"], None, None, None);
-        ensure_param(vp_node, "Origin", "choice", if self.show_origin { "true" } else { "false" }, &["false", "true"], None, None, None);
-        ensure_param(vp_node, "Camera Pivot", "choice", if self.show_camera_pivot { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(vp_node, "Show Grid", "choice", if vp_show_grid { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(vp_node, "Cube", "choice", if vp_show_cube { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(vp_node, "Origin", "choice", if vp_show_origin { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(vp_node, "Camera Pivot", "choice", if vp_show_camera_pivot { "true" } else { "false" }, &["false", "true"], None, None, None);
 
         ensure_param(vp_node, "View", "section", "", &[], None, None, None);
         ensure_param(vp_node, "Close Pane", "button", "", &[], None, None, None);
 
         ensure_param(vp_node, "Settings", "section", "", &[], None, None, None);
-        ensure_param(vp_node, "Show Grid Guide", "choice", if self.show_grid { "true" } else { "false" }, &["false", "true"], None, None, None);
-        ensure_param(vp_node, "Show Reference Cube", "choice", if self.show_cube { "true" } else { "false" }, &["false", "true"], None, None, None);
-        ensure_param(vp_node, "Show Origin Axes", "choice", if self.show_origin { "true" } else { "false" }, &["false", "true"], None, None, None);
-        ensure_param(vp_node, "Show Camera Pivot", "choice", if self.show_camera_pivot { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(vp_node, "Show Grid Guide", "choice", if vp_show_grid { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(vp_node, "Show Reference Cube", "choice", if vp_show_cube { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(vp_node, "Show Origin Axes", "choice", if vp_show_origin { "true" } else { "false" }, &["false", "true"], None, None, None);
+        ensure_param(vp_node, "Show Camera Pivot", "choice", if vp_show_camera_pivot { "true" } else { "false" }, &["false", "true"], None, None, None);
         ensure_param(vp_node, "Grid Thickness", "spinbox", &((self.grid_thickness * 1000.0) as i32).to_string(), &[], Some(2.0), Some(200.0), Some(1.0));
         ensure_param(vp_node, "Origin Guide Size", "spinbox", &((self.origin_size * 10.0) as i32).to_string(), &[], Some(1.0), Some(50.0), Some(1.0));
         ensure_param(vp_node, "Camera Pivot Size", "spinbox", &((self.camera_pivot_size * 10.0) as i32).to_string(), &[], Some(1.0), Some(50.0), Some(1.0));
-        ensure_param(vp_node, "Background Color", "color", &color_to_hex(self.viewport_bg_color), &[], None, None, None);
-        ensure_param(vp_node, "Grid Color", "color", &color_to_hex(self.grid_color), &[], None, None, None);
+        ensure_param(vp_node, "Background Color", "color", &color_to_hex(vp_bg_color), &[], None, None, None);
+        ensure_param(vp_node, "Grid Color", "color", &color_to_hex(vp_grid_color), &[], None, None, None);
 
         // 4. Parameters subnet
         let param_node = find_or_create_subnet(&mut self.fs_root, "Parameters", "utility", (6.0, 0.0));
@@ -473,24 +480,28 @@ impl State {
 
         // Read Settings from Viewport subnet
         if let Some(vp_idx) = self.fs_root.children.iter().position(|c| c.name == "Viewport") {
-            let node = &self.fs_root.children[vp_idx];
-            for p in &node.params {
+            let params = self.fs_root.children[vp_idx].params.clone();
+            for p in &params {
                 match p.name.as_str() {
-                    "Show Grid Guide" => if let Ok(val) = p.default.parse::<bool>() { self.show_grid = val; }
-                    "Show Reference Cube" => if let Ok(val) = p.default.parse::<bool>() { self.show_cube = val; }
-                    "Show Origin Axes" => if let Ok(val) = p.default.parse::<bool>() { self.show_origin = val; }
-                    "Show Camera Pivot" => if let Ok(val) = p.default.parse::<bool>() { self.show_camera_pivot = val; }
+                    "Show Grid Guide" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_grid = val; }
+                    "Show Reference Cube" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_cube = val; }
+                    "Show Origin Axes" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_origin = val; }
+                    "Show Camera Pivot" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_camera_pivot = val; }
                     "Grid Thickness" => if let Ok(val) = p.default.parse::<f32>() { self.grid_thickness = val / 1000.0; }
                     "Origin Guide Size" => if let Ok(val) = p.default.parse::<f32>() { self.origin_size = val / 10.0; }
                     "Camera Pivot Size" => if let Ok(val) = p.default.parse::<f32>() { self.camera_pivot_size = val / 10.0; }
-                    "Background Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_bg_color = col; }
-                    "Grid Color" => if let Some(col) = hex_to_color(&p.default) { self.grid_color = col; }
+                    "Background Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_mut().bg_color = col; }
+                    "Grid Color" => if let Some(col) = hex_to_color(&p.default) { self.viewport_mut().grid_color = col; }
                     "Square Aspect" => if let Ok(val) = p.default.parse::<bool>() { self.square_viewport = val; }
-                    "Show Grid" => if let Ok(val) = p.default.parse::<bool>() { self.show_grid = val; }
-                    "Cube" => if let Ok(val) = p.default.parse::<bool>() { self.show_cube = val; }
-                    "Origin" => if let Ok(val) = p.default.parse::<bool>() { self.show_origin = val; }
-                    "Camera Pivot" => if let Ok(val) = p.default.parse::<bool>() { self.show_camera_pivot = val; }
-                    "Active Camera" => self.active_camera = p.default.clone(),
+                    "Show Grid" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_grid = val; }
+                    "Cube" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_cube = val; }
+                    "Origin" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_origin = val; }
+                    "Camera Pivot" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_camera_pivot = val; }
+                    "Active Camera" => {
+                        let cam = p.default.clone();
+                        self.active_camera = cam.clone();
+                        self.viewport_mut().active_camera = cam;
+                    }
                     _ => {}
                 }
             }
diff --git a/src/window.rs b/src/window.rs
index cd27b93..8f60a3a 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -1390,16 +1390,11 @@ impl AppState {
                             if state.active_camera != "Default Camera" {
                                 state.update_active_camera_rotation_reset();
                             } else {
-                                state.rotation_y = 0.0;
-                                state.rotation_x = 0.0;
+                                state.viewport_mut().rotation_y = 0.0;
+                                state.viewport_mut().rotation_x = 0.0;
                             }
-                            state.viewport_zoom = 1.0;
-                            state.rotate_velocity_yaw = 0.0;
-                            state.rotate_velocity_pitch = 0.0;
-                            state.zoom_velocity = 0.0;
-                            state.is_rotating_viewport = false;
-                            state.is_zooming_viewport = false;
-                            state.scroll_lock = 0;
+                            state.viewport_mut().zoom = 1.0;
+                            state.viewport_mut().reset_velocity();
                             needs_redraw = true;
                             Ok("Camera reset".to_string())
                         }