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

commit726e5e7ada03d9b5659a12be568ea3a46d9941c5
parent5feb9ff3ea
authorLucas Galante <[email protected]>
date2026-05-22 14:58
Implement network pane inertial panning and trackpad 2-finger scroll

 default_project.json |  35 +++
 src/main.rs          | 805 +++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 718 insertions(+), 122 deletions(-)

diff --git a/default_project.json b/default_project.json
new file mode 100644
index 0000000..6456a0d
--- /dev/null
+++ b/default_project.json
@@ -0,0 +1,35 @@
+{
+  "name": "Default Project",
+  "view_state": {
+    "active_camera": "Camera 1",
+    "pan": [0.0, 0.0],
+    "current_path": []
+  },
+  "root": {
+    "name": "root",
+    "type": "node",
+    "position": [0.0, 0.0],
+    "children": [
+      {
+        "name": "Camera 1",
+        "type": "camera",
+        "position": [1.0, 1.0],
+        "params": [
+          { "name": "X", "type": "slider", "default": "2.5", "min": -10.0, "max": 10.0 },
+          { "name": "Y", "type": "slider", "default": "1.8", "min": -10.0, "max": 10.0 },
+          { "name": "Z", "type": "slider", "default": "2.5", "min": -10.0, "max": 10.0 }
+        ],
+        "children": []
+      },
+      {
+        "name": "Sphere 1",
+        "type": "sphere",
+        "position": [4.0, 2.0],
+        "params": [
+          { "name": "Radius", "type": "slider", "default": "0.5" }
+        ],
+        "children": []
+      }
+    ]
+  }
+}
diff --git a/src/main.rs b/src/main.rs
index 71659c3..cc2c939 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,7 +3,7 @@ use std::time::Instant;
 use std::fs;
 use std::path::Path;
 
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
 
 use winit::application::ApplicationHandler;
 use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent};
@@ -13,7 +13,7 @@ use winit::window::{Window, WindowAttributes};
 
 use wgpu::util::DeviceExt;
 
-use clear_ui::widget::{Breadcrumb, Canvas, ContentBg, MenuBar, Node, ParametersBg, Splitter, StatusBar, TextLabel, ViewportBg, Widget};
+use clear_ui::widget::{Breadcrumb, Canvas, ContentBg, MenuBar, Node, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, ViewportBg, Widget};
 use clear_ui::colors;
 
 use glyphon::{
@@ -39,6 +39,8 @@ const STATUS_IDX: usize = NODE_SLOT_START + NODE_SLOT_COUNT;
 const BREADCRUMB_IDX: usize = STATUS_IDX + 1;
 const CONFIG_DIALOG_IDX: usize = BREADCRUMB_IDX + 1;
 const NODE_PALETTE_IDX: usize = CONFIG_DIALOG_IDX + 1;
+const SPREADSHEET_IDX: usize = NODE_PALETTE_IDX + 1;
+const SPREADSHEET_MENUBAR_IDX: usize = SPREADSHEET_IDX + 1;
 
 const HEADER_H: f32 = 40.0;
 const STATUS_H: f32 = 28.0;
@@ -48,7 +50,7 @@ const SPLITTER_W: f32 = 6.0;
 const MIN_COLUMN: f32 = 120.0;
 const BREADCRUMB_H: f32 = 24.0;
 
-#[derive(Clone, Deserialize)]
+#[derive(Clone, Deserialize, Serialize)]
 struct ParamDef {
     name: String,
     #[serde(default)]
@@ -68,7 +70,7 @@ struct ParamDef {
 
 fn default_param_type() -> String { "string".to_string() }
 
-#[derive(Clone, Deserialize)]
+#[derive(Clone, Deserialize, Serialize)]
 struct FsNode {
     name: String,
     #[serde(rename = "type")]
@@ -81,10 +83,35 @@ struct FsNode {
     #[serde(skip)]
     #[serde(default = "default_node_geometry_visible")]
     geometry_visible: bool,
+    #[serde(default = "default_node_position")]
+    position: (f32, f32),
 }
 
 fn default_node_type() -> String { "node".to_string() }
 fn default_node_geometry_visible() -> bool { true }
+fn default_node_position() -> (f32, f32) { (0.0, 0.0) }
+
+#[derive(Clone, Deserialize, Serialize, Default)]
+struct ProjectViewState {
+    #[serde(default = "default_camera")]
+    active_camera: String,
+    #[serde(default)]
+    pan: (f32, f32),
+    #[serde(default)]
+    current_path: Vec<usize>,
+}
+
+fn default_camera() -> String {
+    "Default Camera".to_string()
+}
+
+#[derive(Clone, Deserialize, Serialize)]
+struct Project {
+    name: String,
+    root: FsNode,
+    #[serde(default)]
+    view_state: ProjectViewState,
+}
 
 #[derive(Clone)]
 struct NodeTemplate {
@@ -157,6 +184,7 @@ fn load_fs_tree() -> FsNode {
         children,
         params: vec![],
         geometry_visible: true,
+        position: (0.0, 0.0),
     }
 }
 
@@ -631,6 +659,7 @@ enum Action {
     ToggleCube,
     ToggleSquareViewport,
     ToggleConfigure,
+    ToggleSpreadsheet,
 }
 
 struct KeyBind {
@@ -658,6 +687,7 @@ fn default_keybinds() -> Vec<KeyBind> {
         KeyBind { ctrl: true, shift: false, alt: false, super_: false, key: Key::Character("e".into()), action: Action::ToggleCube },
         KeyBind { ctrl: true, shift: false, alt: false, super_: false, key: Key::Character("a".into()), action: Action::ToggleSquareViewport },
         KeyBind { ctrl: true, shift: false, alt: false, super_: false, key: Key::Character(",".into()), action: Action::ToggleConfigure },
+        KeyBind { ctrl: false, shift: false, alt: false, super_: false, key: Key::Character("`".into()), action: Action::ToggleSpreadsheet },
     ]
 }
 
@@ -791,6 +821,32 @@ fn network_sphere_vertices(root: &FsNode) -> Vec<Vertex3D> {
     out
 }
 
+fn find_sphere_index(root: &FsNode, target: &FsNode) -> Option<usize> {
+    fn visit(node: &FsNode, target: &FsNode, count: &mut usize) -> Option<usize> {
+        let is_target = std::ptr::eq(node, target);
+        if node.node_type.eq_ignore_ascii_case("sphere") {
+            let idx = *count;
+            *count += 1;
+            if is_target {
+                return Some(idx);
+            }
+        }
+        for child in &node.children {
+            if let Some(res) = visit(child, target, count) {
+                return Some(res);
+            }
+        }
+        None
+    }
+    let mut count = 0;
+    for child in &root.children {
+        if let Some(res) = visit(child, target, &mut count) {
+            return Some(res);
+        }
+    }
+    None
+}
+
 fn grid_vertices() -> Vec<Vertex3D> {
     let half_w = 0.015;
     let color = [0.35, 0.35, 0.40];
@@ -914,9 +970,11 @@ struct State {
     node_templates: Vec<NodeTemplate>,
     current_path: Vec<usize>,
     last_click: Option<(Instant, usize)>,
+    last_frame: Instant,
 
     keybinds: Vec<KeyBind>,
     pending_action: Option<Action>,
+    exit_requested: bool,
 
     widgets: Vec<Box<dyn Widget>>,
     positions: Vec<(f32, f32, f32, f32)>,
@@ -961,6 +1019,10 @@ struct State {
 
     pan_x: f32,
     pan_y: f32,
+    pan_velocity_x: f32,
+    pan_velocity_y: f32,
+    last_frame_pan_x: f32,
+    last_frame_pan_y: f32,
     is_panning: bool,
     pan_start_cx: f32,
     pan_start_cy: f32,
@@ -968,6 +1030,11 @@ struct State {
     pan_start_y: f32,
     space_pressed: bool,
     active_camera: String,
+    show_spreadsheet: bool,
+    is_scrolling_trackpad: bool,
+    last_scroll_time: Instant,
+    scroll_accum_x: f32,
+    scroll_accum_y: f32,
 }
 
 impl State {
@@ -1063,7 +1130,8 @@ impl State {
         let Some(&template_idx) = self.node_palette_filtered.get(self.node_palette_selected) else { return false; };
         let child_idx = self.current_dir().children.len();
         if child_idx >= self.node_slots.len() { return false; }
-        let node = self.node_templates[template_idx].node.clone();
+        let mut node = self.node_templates[template_idx].node.clone();
+        node.position = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
         self.current_dir_mut().children.push(node);
         self.left_offsets[child_idx] = self.next_node_offset(child_idx);
         self.close_node_palette();
@@ -1125,10 +1193,10 @@ impl State {
     }
 
     fn sync_nodes(&mut self) {
-        let names: Vec<Option<String>> = {
+        let node_infos: Vec<Option<(String, (f32, f32))>> = {
             let dir = self.current_dir();
             self.node_slots.iter().enumerate().map(|(i, _)| {
-                dir.children.get(i).map(|c| c.name.clone())
+                dir.children.get(i).map(|c| (c.name.clone(), c.position))
             }).collect()
         };
         let params_list: Vec<Vec<(String, String, String)>> = {
@@ -1143,8 +1211,12 @@ impl State {
                 dir.children.get(i).map(|c| c.geometry_visible).unwrap_or(true)
             }).collect()
         };
+
         for (i, &slot) in self.node_slots.iter().enumerate() {
-            if let Some(ref name) = names[i] {
+            if let Some((name, position)) = &node_infos[i] {
+                if i < self.left_offsets.len() {
+                    self.left_offsets[i] = *position;
+                }
                 self.widgets[slot].set_node_name(name);
                 self.widgets[slot].set_display_params(&params_list[i]);
                 self.widgets[slot].set_geom_visible(geom_visibles[i]);
@@ -1170,6 +1242,136 @@ impl State {
         }
         let path_strs = self.current_path_names();
         self.widgets[BREADCRUMB_IDX].set_path(&path_strs);
+
+        let mut selected_node = None;
+        if let Some(focused) = self.focused_widget {
+            if self.node_slots.contains(&focused) {
+                if let Some(slot_idx) = self.node_slots.iter().position(|&x| x == focused) {
+                    let dir = self.current_dir();
+                    if slot_idx < dir.children.len() {
+                        selected_node = Some(&dir.children[slot_idx]);
+                    }
+                }
+            }
+        }
+
+        let mut headers = Vec::new();
+        let mut rows = Vec::new();
+
+        if let Some(node) = selected_node {
+            if node.node_type.eq_ignore_ascii_case("sphere") {
+                if let Some(idx) = find_sphere_index(&self.fs_root, node) {
+                    let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
+                    let radius = node_param_f32(node, "Radius", 0.5).max(0.05);
+                    let verts = sphere_vertices(center, radius);
+
+                    headers = vec![
+                        "Vertex".to_string(),
+                        "Pos X".to_string(),
+                        "Pos Y".to_string(),
+                        "Pos Z".to_string(),
+                        "Col R".to_string(),
+                        "Col G".to_string(),
+                        "Col B".to_string(),
+                    ];
+
+                    for (i, v) in verts.iter().enumerate() {
+                        rows.push(vec![
+                            i.to_string(),
+                            format!("{:.4}", v.position[0]),
+                            format!("{:.4}", v.position[1]),
+                            format!("{:.4}", v.position[2]),
+                            format!("{:.4}", v.color[0]),
+                            format!("{:.4}", v.color[1]),
+                            format!("{:.4}", v.color[2]),
+                        ]);
+                    }
+                }
+            }
+        }
+
+        self.widgets[SPREADSHEET_IDX].set_spreadsheet_data(headers, rows);
+    }
+
+    fn save_to_file(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
+        let proj = Project {
+            name: "Project".to_string(),
+            root: self.fs_root.clone(),
+            view_state: ProjectViewState {
+                active_camera: self.active_camera.clone(),
+                pan: (self.pan_x, self.pan_y),
+                current_path: self.current_path.clone(),
+            },
+        };
+        let content = serde_json::to_string_pretty(&proj)?;
+        fs::write(path, content)?;
+        Ok(())
+    }
+
+    fn load_from_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
+        let content = fs::read_to_string(path)?;
+        let proj: Project = serde_json::from_str(&content)?;
+        self.fs_root = proj.root;
+        self.active_camera = proj.view_state.active_camera;
+        self.pan_x = proj.view_state.pan.0;
+        self.pan_y = proj.view_state.pan.1;
+        self.pan_velocity_x = 0.0;
+        self.pan_velocity_y = 0.0;
+        self.last_frame_pan_x = self.pan_x;
+        self.last_frame_pan_y = self.pan_y;
+        self.is_scrolling_trackpad = false;
+        self.scroll_accum_x = 0.0;
+        self.scroll_accum_y = 0.0;
+        self.current_path = proj.view_state.current_path;
+
+        self.focused_widget = None;
+        self.drag_widget = None;
+        self.last_click = None;
+
+        self.sync_grid_settings();
+        self.sync_nodes();
+        self.rebuild_scene_geometry();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+        self.upload_vertices();
+        Ok(())
+    }
+
+    fn new_project(&mut self) {
+        self.fs_root = FsNode {
+            name: "root".to_string(),
+            node_type: "node".to_string(),
+            children: vec![],
+            params: vec![],
+            geometry_visible: true,
+            position: (0.0, 0.0),
+        };
+        self.active_camera = "Default Camera".to_string();
+        self.pan_x = 0.0;
+        self.pan_y = 0.0;
+        self.pan_velocity_x = 0.0;
+        self.pan_velocity_y = 0.0;
+        self.last_frame_pan_x = 0.0;
+        self.last_frame_pan_y = 0.0;
+        self.is_scrolling_trackpad = false;
+        self.scroll_accum_x = 0.0;
+        self.scroll_accum_y = 0.0;
+        self.current_path.clear();
+        self.grid_cursor_col = 0;
+        self.grid_cursor_row = 0;
+
+        self.focused_widget = None;
+        self.drag_widget = None;
+        self.last_click = None;
+
+        self.sync_grid_settings();
+        self.sync_nodes();
+        self.rebuild_scene_geometry();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+        self.upload_vertices();
     }
 
     fn create_depth_texture(&self) -> (wgpu::Texture, wgpu::TextureView) {
@@ -1432,8 +1634,49 @@ impl State {
             (tex, view)
         };
 
-        let fs_root = load_fs_tree();
-        let node_templates = flatten_node_templates(&fs_root);
+        let templates_root = load_fs_tree();
+        let node_templates = flatten_node_templates(&templates_root);
+
+        let default_proj_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
+        let mut loaded_project = None;
+        if default_proj_path.exists() {
+            if let Ok(content) = fs::read_to_string(&default_proj_path) {
+                if let Ok(proj) = serde_json::from_str::<Project>(&content) {
+                    loaded_project = Some(proj);
+                }
+            }
+        }
+
+        let fs_root = if let Some(ref proj) = loaded_project {
+            proj.root.clone()
+        } else {
+            FsNode {
+                name: "root".to_string(),
+                node_type: "node".to_string(),
+                children: vec![],
+                params: vec![],
+                geometry_visible: true,
+                position: (0.0, 0.0),
+            }
+        };
+
+        let active_camera = if let Some(ref proj) = loaded_project {
+            proj.view_state.active_camera.clone()
+        } else {
+            "Default Camera".to_string()
+        };
+
+        let (pan_x, pan_y) = if let Some(ref proj) = loaded_project {
+            proj.view_state.pan
+        } else {
+            (0.0, 0.0)
+        };
+
+        let current_path = if let Some(ref proj) = loaded_project {
+            proj.view_state.current_path.clone()
+        } else {
+            vec![]
+        };
         let node_slots: Vec<usize> = (NODE_SLOT_START..NODE_SLOT_START + NODE_SLOT_COUNT).collect();
         let left_offsets: Vec<(f32, f32)> = (0..NODE_SLOT_COUNT)
             .map(|i| (0.0, i as f32))
@@ -1458,9 +1701,14 @@ impl State {
         widgets.push(Box::new(Breadcrumb::new()));
         widgets.push(Box::new(ConfigDialog::new()));
         widgets.push(Box::new(NodePalette::new()));
+        widgets.push(Box::new(Spreadsheet::new()));
+        
+        let mut spreadsheet_menubar = MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("spreadsheed");
+        spreadsheet_menubar.visible = false;
+        widgets.push(Box::new(spreadsheet_menubar));
 
-        let mut positions = Vec::with_capacity(NODE_PALETTE_IDX + 1);
-        positions.resize_with(NODE_PALETTE_IDX + 1, || (0.0, 0.0, 0.0, 0.0));
+        let mut positions = Vec::with_capacity(SPREADSHEET_MENUBAR_IDX + 1);
+        positions.resize_with(SPREADSHEET_MENUBAR_IDX + 1, || (0.0, 0.0, 0.0, 0.0));
 
         let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
             label: Some("Vertex Buffer"),
@@ -1497,10 +1745,12 @@ impl State {
             show_cube: false,
             fs_root,
             node_templates,
-            current_path: Vec::new(),
+            current_path,
             last_click: None,
+            last_frame: Instant::now(),
             keybinds: default_keybinds(),
             pending_action: None,
+            exit_requested: false,
             widgets,
             positions,
             node_slots,
@@ -1537,15 +1787,24 @@ impl State {
             skipped_row_h: 20.0,
             skipped_col_w: 20.0,
 
-            pan_x: 0.0,
-            pan_y: 0.0,
+            pan_x,
+            pan_y,
+            pan_velocity_x: 0.0,
+            pan_velocity_y: 0.0,
+            last_frame_pan_x: pan_x,
+            last_frame_pan_y: pan_y,
             is_panning: false,
             pan_start_cx: 0.0,
             pan_start_cy: 0.0,
             pan_start_x: 0.0,
             pan_start_y: 0.0,
             space_pressed: false,
-            active_camera: "Default Camera".to_string(),
+            active_camera,
+            show_spreadsheet: false,
+            is_scrolling_trackpad: false,
+            last_scroll_time: Instant::now(),
+            scroll_accum_x: 0.0,
+            scroll_accum_y: 0.0,
         };
 
         state.sync_nodes();
@@ -1577,6 +1836,11 @@ impl State {
     }
 
     fn zoom(&mut self, factor: f32, center: Option<(f32, f32)>) {
+        self.pan_velocity_x = 0.0;
+        self.pan_velocity_y = 0.0;
+        self.is_scrolling_trackpad = false;
+        self.scroll_accum_x = 0.0;
+        self.scroll_accum_y = 0.0;
         let old_gx = self.grid_size_x;
         let old_gy = self.grid_size_y;
 
@@ -1656,11 +1920,23 @@ impl State {
         self.positions[0] = (0.0, 0.0, self.width, HEADER_H);
         self.positions[CONTENT_IDX] = (0.0, HEADER_H, clw, body_h);
         self.positions[SPLITTER1_IDX] = (self.splitter1_x, HEADER_H, SPLITTER_W, body_h);
-        self.positions[VIEWPORT_IDX] = (crx, HEADER_H, vw, body_h);
         self.positions[SPLITTER2_IDX] = (self.splitter2_x, HEADER_H, SPLITTER_W, body_h);
         self.positions[PARAM_IDX] = (px, HEADER_H, self.param_w(), body_h);
+
+        self.positions[VIEWPORT_IDX] = (crx, HEADER_H, vw, body_h);
         self.positions[CANVAS_IDX] = (0.0, HEADER_H, self.width, body_h);
 
+        if self.show_spreadsheet {
+            let viewport_h = body_h * 2.0 / 3.0;
+            let spreadsheet_h = body_h - viewport_h;
+
+            self.positions[SPREADSHEET_MENUBAR_IDX] = (crx, HEADER_H + viewport_h, vw, MENUBAR_H);
+            self.positions[SPREADSHEET_IDX] = (crx, HEADER_H + viewport_h + MENUBAR_H, vw, spreadsheet_h - MENUBAR_H);
+        } else {
+            self.positions[SPREADSHEET_IDX] = (0.0, 0.0, 0.0, 0.0);
+            self.positions[SPREADSHEET_MENUBAR_IDX] = (0.0, 0.0, 0.0, 0.0);
+        }
+
         self.positions[LEFT_MENUBAR_IDX] = (0.0, HEADER_H, clw, MENUBAR_H);
         self.positions[RIGHT_MENUBAR_IDX] = (crx, HEADER_H, vw, MENUBAR_H);
         self.positions[PARAM_MENUBAR_IDX] = (px, HEADER_H, self.param_w(), MENUBAR_H);
@@ -1729,6 +2005,14 @@ impl State {
                 self.upload_vertices();
                 return;
             }
+            Action::ToggleSpreadsheet => {
+                self.show_spreadsheet = !self.show_spreadsheet;
+                self.widgets[SPREADSHEET_IDX].set_visible(self.show_spreadsheet);
+                self.widgets[SPREADSHEET_MENUBAR_IDX].set_visible(self.show_spreadsheet);
+                self.rebuild_positions();
+                self.apply_layout();
+                self.sync_nodes();
+            }
         }
 
     }
@@ -1736,19 +2020,32 @@ impl State {
     fn read_panel_offsets(&mut self) {
         let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
         let active_nodes = self.current_dir().children.len().min(self.node_slots.len());
+        let mut coords = Vec::with_capacity(active_nodes);
         for (&idx, off) in self.node_slots.iter().take(active_nodes).zip(self.left_offsets.iter_mut()) {
             let (px, py, _, _) = self.widgets[idx].rect();
             let c = ((px - self.pan_x) / (self.grid_size_x + self.skipped_col_w)).round();
             let r = ((py - node_area_y - self.pan_y) / (self.grid_size_y + self.skipped_row_h)).round();
             *off = (c, r);
+            coords.push((c, r));
             if Some(idx) == self.focused_widget && self.drag_widget == Some(idx) {
                 self.grid_cursor_col = c as i32;
                 self.grid_cursor_row = r as i32;
             }
         }
+        let dir = self.current_dir_mut();
+        for (i, coord) in coords.into_iter().enumerate() {
+            if let Some(child) = dir.children.get_mut(i) {
+                child.position = coord;
+            }
+        }
     }
 
     fn sync_cursor_and_selection(&mut self) {
+        if let Some(old) = self.focused_widget {
+            if !self.node_slots.contains(&old) && old != SPREADSHEET_IDX {
+                return;
+            }
+        }
         let active_nodes = self.current_dir().children.len().min(self.node_slots.len());
         let mut node_at_cursor = None;
         for (slot_idx, (&idx, off)) in self.node_slots.iter().zip(self.left_offsets.iter()).enumerate() {
@@ -1968,7 +2265,7 @@ impl State {
 
     fn handle_event(&mut self, event: &WindowEvent) -> bool {
         match event {
-            WindowEvent::MouseWheel { delta, .. } => {
+            WindowEvent::MouseWheel { delta, phase, .. } => {
                 let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
                 let dialog_open = self.widgets[CONFIG_DIALOG_IDX].visible() || self.node_palette_visible;
                 let in_network_pane = self.cursor_x >= 0.0
@@ -2038,18 +2335,37 @@ impl State {
                             false
                         }
                     } else {
-                        let (dx, dy) = match delta {
+                        match delta {
                             winit::event::MouseScrollDelta::LineDelta(x, y) => {
-                                (*x * 30.0, *y * 30.0)
+                                let dx = *x * 30.0;
+                                let dy = *y * 30.0;
+                                self.pan_x -= dx;
+                                self.pan_y -= dy;
+                                self.is_scrolling_trackpad = false;
+                                let dt_scroll = Instant::now().duration_since(self.last_frame).as_secs_f32().min(0.1);
+                                let vel_x = if dt_scroll > 1e-4 { -dx / dt_scroll } else { -dx * 60.0 };
+                                let vel_y = if dt_scroll > 1e-4 { -dy / dt_scroll } else { -dy * 60.0 };
+                                self.pan_velocity_x = self.pan_velocity_x * 0.4 + vel_x * 0.6;
+                                self.pan_velocity_y = self.pan_velocity_y * 0.4 + vel_y * 0.6;
+                                self.sync_grid_settings();
+                                true
                             }
                             winit::event::MouseScrollDelta::PixelDelta(pos) => {
-                                (pos.x as f32 / self.scale as f32, pos.y as f32 / self.scale as f32)
+                                let dx = pos.x as f32 / self.scale as f32;
+                                let dy = pos.y as f32 / self.scale as f32;
+                                self.pan_x -= dx;
+                                self.pan_y -= dy;
+                                self.is_scrolling_trackpad = match phase {
+                                    winit::event::TouchPhase::Started | winit::event::TouchPhase::Moved => true,
+                                    winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => false,
+                                };
+                                self.last_scroll_time = Instant::now();
+                                self.scroll_accum_x += dx;
+                                self.scroll_accum_y += dy;
+                                self.sync_grid_settings();
+                                true
                             }
-                        };
-                        self.pan_x -= dx;
-                        self.pan_y -= dy;
-                        self.sync_grid_settings();
-                        true
+                        }
                     }
                 } else {
                     false
@@ -2108,6 +2424,13 @@ impl State {
                 changed
             }
             WindowEvent::MouseInput { state: btn_state, button, .. } => {
+                if *btn_state == ElementState::Pressed {
+                    self.pan_velocity_x = 0.0;
+                    self.pan_velocity_y = 0.0;
+                    self.is_scrolling_trackpad = false;
+                    self.scroll_accum_x = 0.0;
+                    self.scroll_accum_y = 0.0;
+                }
                 let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
                 let dialog_open = self.widgets[CONFIG_DIALOG_IDX].visible() || self.node_palette_visible;
                 let in_network_pane = self.cursor_x >= 0.0
@@ -2129,6 +2452,10 @@ impl State {
                             self.pan_start_cy = self.cursor_y;
                             self.pan_start_x = self.pan_x;
                             self.pan_start_y = self.pan_y;
+                            self.pan_velocity_x = 0.0;
+                            self.pan_velocity_y = 0.0;
+                            self.last_frame_pan_x = self.pan_x;
+                            self.last_frame_pan_y = self.pan_y;
                             return true;
                         }
                         ElementState::Released => {
@@ -2183,6 +2510,10 @@ impl State {
                             if i != PARAM_IDX {
                                 self.widgets[i].focus();
                                 self.focused_widget = Some(i);
+                                if self.widgets[i].is_menu_bar() && !self.widgets[i].is_menu_open() {
+                                    self.widgets[i].unfocus();
+                                    self.focused_widget = None;
+                                }
                             }
                             if self.node_slots.contains(&i) {
                                 if let Some(slot_idx) = self.node_slots.iter().position(|&x| x == i) {
@@ -2201,6 +2532,13 @@ impl State {
                                             self.current_path.push(dir_idx);
                                             self.pan_x = 0.0;
                                             self.pan_y = 0.0;
+                                            self.pan_velocity_x = 0.0;
+                                            self.pan_velocity_y = 0.0;
+                                            self.last_frame_pan_x = 0.0;
+                                            self.last_frame_pan_y = 0.0;
+                                            self.is_scrolling_trackpad = false;
+                                            self.scroll_accum_x = 0.0;
+                                            self.scroll_accum_y = 0.0;
                                             self.grid_cursor_col = 0;
                                             self.grid_cursor_row = 0;
                                             self.sync_grid_settings();
@@ -2270,68 +2608,97 @@ impl State {
                 if event.state == ElementState::Pressed {
                     if !self.widgets[CONFIG_DIALOG_IDX].visible() {
                         let is_plain_key = !self.modifiers.control_key() && !self.modifiers.alt_key() && !self.modifiers.super_key();
+                        let is_alt_key = self.modifiers.alt_key() && !self.modifiers.control_key() && !self.modifiers.super_key();
+
+                        let mut delta = None;
 
                         match &event.logical_key {
                             Key::Named(NamedKey::ArrowUp) => {
-                                self.grid_cursor_row -= 1;
-                                changed = true;
+                                delta = Some((0, -1));
                             }
                             Key::Named(NamedKey::ArrowDown) => {
-                                self.grid_cursor_row += 1;
-                                changed = true;
+                                delta = Some((0, 1));
                             }
                             Key::Named(NamedKey::ArrowLeft) => {
-                                self.grid_cursor_col -= 1;
-                                changed = true;
+                                delta = Some((-1, 0));
                             }
                             Key::Named(NamedKey::ArrowRight) => {
-                                self.grid_cursor_col += 1;
-                                changed = true;
+                                delta = Some((1, 0));
                             }
-                            Key::Character(s) if is_plain_key => {
+                            Key::Character(s) => {
                                 match s.as_str() {
-                                    "k" | "K" => {
-                                        self.grid_cursor_row -= 1;
-                                        changed = true;
+                                    "k" | "K" if is_plain_key || is_alt_key => {
+                                        delta = Some((0, -1));
                                     }
-                                    "j" | "J" => {
-                                        self.grid_cursor_row += 1;
-                                        changed = true;
+                                    "j" | "J" if is_plain_key || is_alt_key => {
+                                        delta = Some((0, 1));
                                     }
-                                    "h" | "H" => {
-                                        self.grid_cursor_col -= 1;
-                                        changed = true;
+                                    "h" | "H" if is_plain_key || is_alt_key => {
+                                        delta = Some((-1, 0));
                                     }
-                                    "l" | "L" => {
-                                        self.grid_cursor_col += 1;
-                                        changed = true;
+                                    "l" | "L" if is_plain_key || is_alt_key => {
+                                        delta = Some((1, 0));
                                     }
-                                    "e" | "E" => {
-                                        if let Some(idx) = self.focused_widget {
-                                            if let Some(slot_idx) = self.node_slots.iter().position(|&x| x == idx) {
-                                                let active_nodes = self.current_dir().children.len().min(self.node_slots.len());
-                                                if slot_idx < active_nodes {
-                                                    let visible = !self.widgets[idx].geom_visible();
-                                                    self.widgets[idx].set_geom_visible(visible);
-                                                    self.current_dir_mut().children[slot_idx].geometry_visible = visible;
-                                                    self.rebuild_scene_geometry();
-                                                    changed = true;
+                                    _ => {}
+                                }
+                            }
+                            _ => {}
+                        }
+
+                        if let Some((dc, dr)) = delta {
+                            self.pan_velocity_x = 0.0;
+                            self.pan_velocity_y = 0.0;
+                            self.is_scrolling_trackpad = false;
+                            self.scroll_accum_x = 0.0;
+                            self.scroll_accum_y = 0.0;
+                            if is_alt_key {
+                                let active_nodes = self.current_dir().children.len().min(self.node_slots.len());
+                                let node_idx_at_cursor = self.current_dir().children.iter().take(active_nodes).position(|child| {
+                                    child.position.0 as i32 == self.grid_cursor_col && child.position.1 as i32 == self.grid_cursor_row
+                                });
+                                if let Some(idx) = node_idx_at_cursor {
+                                    let new_x = self.current_dir().children[idx].position.0 + dc as f32;
+                                    let new_y = self.current_dir().children[idx].position.1 + dr as f32;
+                                    self.current_dir_mut().children[idx].position = (new_x, new_y);
+                                    self.left_offsets[idx] = (new_x, new_y);
+                                    self.sync_nodes();
+                                    self.sync_layout();
+                                    self.upload_vertices();
+                                }
+                            }
+                            self.grid_cursor_col += dc;
+                            self.grid_cursor_row += dr;
+                            changed = true;
+                        } else {
+                            if let Key::Character(s) = &event.logical_key {
+                                if is_plain_key {
+                                    match s.as_str() {
+                                        "e" | "E" => {
+                                            if let Some(idx) = self.focused_widget {
+                                                if let Some(slot_idx) = self.node_slots.iter().position(|&x| x == idx) {
+                                                    let active_nodes = self.current_dir().children.len().min(self.node_slots.len());
+                                                    if slot_idx < active_nodes {
+                                                        let visible = !self.widgets[idx].geom_visible();
+                                                        self.widgets[idx].set_geom_visible(visible);
+                                                        self.current_dir_mut().children[slot_idx].geometry_visible = visible;
+                                                        self.rebuild_scene_geometry();
+                                                        changed = true;
+                                                    }
                                                 }
                                             }
                                         }
+                                        "-" => {
+                                            self.zoom(1.0 / 1.15, None);
+                                            changed = true;
+                                        }
+                                        "=" | "+" => {
+                                            self.zoom(1.15, None);
+                                            changed = true;
+                                        }
+                                        _ => {}
                                     }
-                                    "-" => {
-                                        self.zoom(1.0 / 1.15, None);
-                                        changed = true;
-                                    }
-                                    "=" | "+" => {
-                                        self.zoom(1.15, None);
-                                        changed = true;
-                                    }
-                                    _ => {}
                                 }
                             }
-                            _ => {}
                         }
                     }
 
@@ -2363,6 +2730,71 @@ impl State {
     }
 
     fn render(&mut self) {
+        let now = Instant::now();
+        let dt = now.duration_since(self.last_frame).as_secs_f32().min(0.1);
+        self.last_frame = now;
+
+        // Panning velocity tracking
+        if self.is_panning {
+            if dt > 1e-5 {
+                let diff_x = self.pan_x - self.last_frame_pan_x;
+                let diff_y = self.pan_y - self.last_frame_pan_y;
+                self.pan_velocity_x = self.pan_velocity_x * 0.4 + (diff_x / dt) * 0.6;
+                self.pan_velocity_y = self.pan_velocity_y * 0.4 + (diff_y / dt) * 0.6;
+            }
+            self.last_frame_pan_x = self.pan_x;
+            self.last_frame_pan_y = self.pan_y;
+        }
+
+        // Trackpad scrolling velocity tracking & timeout detection
+        if self.is_scrolling_trackpad {
+            if now.duration_since(self.last_scroll_time).as_secs_f32() > 0.05 {
+                self.is_scrolling_trackpad = false;
+            } else if dt > 1e-5 {
+                let vel_x = -self.scroll_accum_x / dt;
+                let vel_y = -self.scroll_accum_y / dt;
+                self.pan_velocity_x = self.pan_velocity_x * 0.4 + vel_x * 0.6;
+                self.pan_velocity_y = self.pan_velocity_y * 0.4 + vel_y * 0.6;
+            }
+            self.scroll_accum_x = 0.0;
+            self.scroll_accum_y = 0.0;
+        }
+
+        let mut tick_changed = false;
+        for w in &mut self.widgets {
+            if w.tick(dt) {
+                tick_changed = true;
+            }
+        }
+
+        // Panning kinetic slide
+        if !self.is_panning && !self.is_scrolling_trackpad && (self.pan_velocity_x.abs() > 0.01 || self.pan_velocity_y.abs() > 0.01) {
+            self.pan_x += self.pan_velocity_x * dt;
+            self.pan_y += self.pan_velocity_y * dt;
+
+            // Apply friction decay
+            let friction = 5.0_f32;
+            let decay = (-friction * dt).exp();
+            self.pan_velocity_x *= decay;
+            self.pan_velocity_y *= decay;
+
+            // If velocity magnitude is below 10.0 px/sec, stop completely.
+            if self.pan_velocity_x.hypot(self.pan_velocity_y) < 10.0 {
+                self.pan_velocity_x = 0.0;
+                self.pan_velocity_y = 0.0;
+            }
+
+            self.sync_grid_settings();
+            self.rebuild_positions();
+            self.apply_layout();
+            self.update_panel_bounds();
+            tick_changed = true;
+        }
+
+        if tick_changed {
+            self.upload_vertices();
+        }
+
         let mut panned = false;
         if let Some(idx) = self.drag_widget {
             if self.node_slots.contains(&idx) {
@@ -2395,6 +2827,11 @@ impl State {
         }
 
         if panned {
+            self.pan_velocity_x = 0.0;
+            self.pan_velocity_y = 0.0;
+            self.is_scrolling_trackpad = false;
+            self.scroll_accum_x = 0.0;
+            self.scroll_accum_y = 0.0;
             self.sync_grid_settings();
             if let Some(idx) = self.drag_widget {
                 self.widgets[idx].drag_update(self.cursor_x, self.cursor_y);
@@ -2424,7 +2861,7 @@ impl State {
 
         // 3D canvas render pass (background layer)
         {
-            let (cx, cy, cw_logical, ch_logical) = self.widgets[CANVAS_IDX].rect();
+            let (cx, cy, cw_logical, ch_logical) = self.widgets[VIEWPORT_IDX].rect();
             let mut cw = (cw_logical * self.scale as f32) as u32;
             let mut ch = (ch_logical * self.scale as f32) as u32;
             let mut sx = (cx * self.scale as f32) as u32;
@@ -2604,73 +3041,93 @@ impl ApplicationHandler for App {
             _ => {
                 if let Some(state) = &mut self.state {
                     let mut changed = state.handle_event(&event);
-                    if changed {
-                        if let Some(focused) = state.focused_widget {
-                            if state.node_slots.contains(&focused) {
-                                if let Some(slot_idx) = state.node_slots.iter().position(|&x| x == focused) {
-                                    let updated_params = state.widgets[PARAM_IDX].node_params();
-                                    let dir = state.current_dir_mut();
-                                    if let Some(child) = dir.children.get_mut(slot_idx) {
-                                        let mut param_changed = false;
-                                        for (u_name, u_val, _type) in &updated_params {
-                                            if let Some(p) = child.params.iter_mut().find(|p| p.name == *u_name) {
-                                                if p.default != *u_val {
-                                                    p.default = u_val.clone();
-                                                    param_changed = true;
-                                                }
-                                            }
-                                        }
-                                        if param_changed {
-                                            state.rebuild_scene_geometry();
-                                            state.sync_nodes();
-                                        }
-                                    }
-                                }
-                            }
-                        }
-
-                        state.sync_layout();
-                        state.read_panel_offsets();
-                        state.upload_vertices();
-                    }
-
-                    // Sync Parameters pane with selected node
-                    let params = state.focused_widget.and_then(|f| {
-                        if state.node_slots.contains(&f) { Some(f) } else { None }
-                    }).map(|f| state.widgets[f].node_params()).unwrap_or_default();
-                    state.widgets[PARAM_IDX].set_display_params(&params);
 
                     if let Some(seg) = state.widgets[BREADCRUMB_IDX].path_click() {
                         if seg < state.current_path.len() {
                             state.current_path.truncate(seg);
                             state.pan_x = 0.0;
                             state.pan_y = 0.0;
+                            state.pan_velocity_x = 0.0;
+                            state.pan_velocity_y = 0.0;
+                            state.last_frame_pan_x = 0.0;
+                            state.last_frame_pan_y = 0.0;
+                            state.is_scrolling_trackpad = false;
+                            state.scroll_accum_x = 0.0;
+                            state.scroll_accum_y = 0.0;
                             state.grid_cursor_col = 0;
                             state.grid_cursor_row = 0;
                             state.sync_grid_settings();
-                            state.sync_nodes();
-                            state.sync_layout();
-                            state.read_panel_offsets();
-                            state.upload_vertices();
                             changed = true;
                         }
                     }
 
                     if let Some(action) = state.pending_action.take() {
                         state.execute_action(action);
+                        changed = true;
                     }
 
                     if let Some((menu_idx, item_idx)) = state.widgets[HEADER_IDX].menu_click() {
-                        if menu_idx == 0 && item_idx == 3 { // File > Configure
-                            state.widgets[CONFIG_DIALOG_IDX].set_config_toggle(0, state.grid_snap_enabled);
-                            state.widgets[CONFIG_DIALOG_IDX].set_config_toggle(1, state.network_grid_visible);
-                            state.widgets[CONFIG_DIALOG_IDX].set_config_spin(0, state.grid_size_x);
-                            state.widgets[CONFIG_DIALOG_IDX].set_config_spin(1, state.grid_size_y);
-                            state.widgets[CONFIG_DIALOG_IDX].set_config_spin(2, state.skipped_row_h);
-                            state.widgets[CONFIG_DIALOG_IDX].set_config_spin(3, state.skipped_col_w);
-                            state.widgets[CONFIG_DIALOG_IDX].set_visible(true);
-                            state.upload_vertices();
-                            changed = true;
+                        if menu_idx == 0 { // File
+                            match item_idx {
+                                0 => { // New Project
+                                    state.new_project();
+                                    changed = true;
+                                }
+                                1 => { // Open
+                                    let proj_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("project.json");
+                                    if let Err(e) = state.load_from_file(&proj_path) {
+                                        eprintln!("Failed to load project: {:?}", e);
+                                    }
+                                    changed = true;
+                                }
+                                2 => { // Save
+                                    let proj_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("project.json");
+                                    if let Err(e) = state.save_to_file(&proj_path) {
+                                        eprintln!("Failed to save project: {:?}", e);
+                                    }
+                                    changed = true;
+                                }
+                                3 => { // Configure
+                                    state.widgets[CONFIG_DIALOG_IDX].set_config_toggle(0, state.grid_snap_enabled);
+                                    state.widgets[CONFIG_DIALOG_IDX].set_config_toggle(1, state.network_grid_visible);
+                                    state.widgets[CONFIG_DIALOG_IDX].set_config_spin(0, state.grid_size_x);
+                                    state.widgets[CONFIG_DIALOG_IDX].set_config_spin(1, state.grid_size_y);
+                                    state.widgets[CONFIG_DIALOG_IDX].set_config_spin(2, state.skipped_row_h);
+                                    state.widgets[CONFIG_DIALOG_IDX].set_config_spin(3, state.skipped_col_w);
+                                    state.widgets[CONFIG_DIALOG_IDX].set_visible(true);
+                                    changed = true;
+                                }
+                                4 => { // Exit
+                                    state.exit_requested = true;
+                                }
+                                _ => {}
+                            }
+                        }
+                    }
+
+                    if let Some((menu_idx, item_idx)) = state.widgets[LEFT_MENUBAR_IDX].menu_click() {
+                        if menu_idx == 0 { // File
+                            match item_idx {
+                                0 => { // New
+                                    state.new_project();
+                                    changed = true;
+                                }
+                                1 => { // Open
+                                    let proj_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("project.json");
+                                    if let Err(e) = state.load_from_file(&proj_path) {
+                                        eprintln!("Failed to load project: {:?}", e);
+                                    }
+                                    changed = true;
+                                }
+                                2 => { // Save
+                                    let proj_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("project.json");
+                                    if let Err(e) = state.save_to_file(&proj_path) {
+                                        eprintln!("Failed to save project: {:?}", e);
+                                    }
+                                    changed = true;
+                                }
+                                _ => {}
+                            }
                         }
                     }
 
@@ -2699,7 +3156,10 @@ impl ApplicationHandler for App {
                                     _ => None,
                                 }
                             } else { None };
-                            if let Some(a) = action { state.execute_action(a); }
+                            if let Some(a) = action {
+                                state.execute_action(a);
+                                changed = true;
+                            }
                         }
                     }
 
@@ -2719,9 +3179,9 @@ impl ApplicationHandler for App {
                             }
                             _ => {}
                         }
-                        state.upload_vertices();
                         changed = true;
                     }
+
                     while let Some((id, val)) = state.widgets[CONFIG_DIALOG_IDX].take_config_spin() {
                         match id {
                             0 => state.grid_size_x = val,
@@ -2734,15 +3194,48 @@ impl ApplicationHandler for App {
                             let (x, y, _, _) = state.widgets[i].rect();
                             state.widgets[i].set_rect(x, y, state.grid_size_x, state.grid_size_y);
                         }
-                        state.sync_layout();
                         state.sync_grid_settings();
-                        state.read_panel_offsets();
-                        state.upload_vertices();
                         changed = true;
                     }
 
                     if changed {
+                        state.sync_layout();
+                        state.read_panel_offsets();
                         state.sync_cursor_and_selection();
+
+                        if state.drag_widget == Some(PARAM_IDX) && state.widgets[PARAM_IDX].is_dragging() {
+                            if let Some(focused) = state.focused_widget {
+                                if state.node_slots.contains(&focused) {
+                                    if let Some(slot_idx) = state.node_slots.iter().position(|&x| x == focused) {
+                                        let updated_params = state.widgets[PARAM_IDX].node_params();
+                                        let dir = state.current_dir_mut();
+                                        if let Some(child) = dir.children.get_mut(slot_idx) {
+                                            let mut param_changed = false;
+                                            for (u_name, u_val, _type) in &updated_params {
+                                                if let Some(p) = child.params.iter_mut().find(|p| p.name == *u_name) {
+                                                    if p.default != *u_val {
+                                                        p.default = u_val.clone();
+                                                        param_changed = true;
+                                                    }
+                                                }
+                                            }
+                                            if param_changed {
+                                                state.rebuild_scene_geometry();
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+
+                        state.sync_nodes();
+
+                        // Sync Parameters pane with selected node
+                        let params = state.focused_widget.and_then(|f| {
+                            if state.node_slots.contains(&f) { Some(f) } else { None }
+                        }).map(|f| state.widgets[f].node_params()).unwrap_or_default();
+                        state.widgets[PARAM_IDX].set_display_params(&params);
+
                         state.upload_vertices();
                     }
 
@@ -2757,6 +3250,11 @@ impl ApplicationHandler for App {
         if needs_redraw {
             if let Some(state) = &mut self.state { state.window.request_redraw(); }
         }
+        if let Some(state) = &self.state {
+            if state.exit_requested {
+                event_loop.exit();
+            }
+        }
     }
 }
 
@@ -2766,3 +3264,66 @@ fn main() {
     let mut app = App::new();
     event_loop.run_app(&mut app).unwrap();
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_load_default_project() {
+        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
+        let content = fs::read_to_string(&path).expect("failed to read default project");
+        let proj: Project = serde_json::from_str(&content).expect("failed to deserialize project");
+        assert_eq!(proj.name, "Default Project");
+        assert_eq!(proj.view_state.active_camera, "Camera 1");
+        assert_eq!(proj.root.name, "root");
+        assert_eq!(proj.root.children.len(), 2);
+        assert_eq!(proj.root.children[0].name, "Camera 1");
+        assert_eq!(proj.root.children[0].position, (1.0, 1.0));
+        assert_eq!(proj.root.children[1].name, "Sphere 1");
+        assert_eq!(proj.root.children[1].position, (4.0, 2.0));
+    }
+
+    #[test]
+    fn test_project_serialization_roundtrip() {
+        let root = FsNode {
+            name: "test_root".to_string(),
+            node_type: "node".to_string(),
+            children: vec![
+                FsNode {
+                    name: "child1".to_string(),
+                    node_type: "sphere".to_string(),
+                    children: vec![],
+                    params: vec![],
+                    geometry_visible: true,
+                    position: (5.0, 6.0),
+                }
+            ],
+            params: vec![],
+            geometry_visible: true,
+            position: (0.0, 0.0),
+        };
+        let view_state = ProjectViewState {
+            active_camera: "child1".to_string(),
+            pan: (1.5, -2.5),
+            current_path: vec![0],
+        };
+        let proj = Project {
+            name: "Test Project".to_string(),
+            root,
+            view_state,
+        };
+
+        let content = serde_json::to_string(&proj).expect("failed to serialize");
+        let proj2: Project = serde_json::from_str(&content).expect("failed to deserialize");
+
+        assert_eq!(proj.name, proj2.name);
+        assert_eq!(proj.view_state.active_camera, proj2.view_state.active_camera);
+        assert_eq!(proj.view_state.pan, proj2.view_state.pan);
+        assert_eq!(proj.view_state.current_path, proj2.view_state.current_path);
+        assert_eq!(proj.root.name, proj2.root.name);
+        assert_eq!(proj.root.children.len(), proj2.root.children.len());
+        assert_eq!(proj.root.children[0].name, proj2.root.children[0].name);
+        assert_eq!(proj.root.children[0].position, proj2.root.children[0].position);
+    }
+}