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

commit3a763e959e69c8411bed213c8bbdeb5676bdca1c
parent59522a3487
authorLucas Galante <[email protected]>
date2026-05-21 19:02
Implement canvas zoom functionality using -/= keys

 Cargo.lock         |   54 +-
 Cargo.toml         |    4 +-
 src/main.rs        | 2205 ++++++++++++++++++++++++++++++++++++++++++++++++----
 src/shader_3d.wgsl |   26 +
 4 files changed, 2104 insertions(+), 185 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 41088a7..076dfaf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -237,9 +237,11 @@ version = "0.1.0"
 dependencies = [
  "bytemuck",
  "clear-ui",
+ "glam",
  "glyphon",
  "pollster",
- "taffy",
+ "serde",
+ "serde_json",
  "tokio",
  "wgpu",
  "winit",
@@ -252,7 +254,6 @@ dependencies = [
  "bytemuck",
  "glyphon",
  "pollster",
- "taffy",
  "tokio",
  "wgpu",
  "winit",
@@ -586,6 +587,12 @@ dependencies = [
  "xml-rs",
 ]
 
+[[package]]
+name = "glam"
+version = "0.29.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
+
 [[package]]
 name = "glow"
 version = "0.16.0"
@@ -671,12 +678,6 @@ dependencies = [
  "bitflags 2.11.1",
 ]
 
-[[package]]
-name = "grid"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
-
 [[package]]
 name = "hashbrown"
 version = "0.15.5"
@@ -720,6 +721,12 @@ dependencies = [
  "hashbrown 0.17.1",
 ]
 
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
 [[package]]
 name = "jni"
 version = "0.22.4"
@@ -1658,6 +1665,19 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
 [[package]]
 name = "shlex"
 version = "1.3.0"
@@ -1845,18 +1865,6 @@ dependencies = [
  "libc",
 ]
 
-[[package]]
-name = "taffy"
-version = "0.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859"
-dependencies = [
- "arrayvec",
- "grid",
- "serde",
- "slotmap",
-]
-
 [[package]]
 name = "termcolor"
 version = "1.4.1"
@@ -2753,3 +2761,9 @@ dependencies = [
  "quote",
  "syn",
 ]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
index 318aa72..bbd7d9e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,8 @@ winit = "0.30"
 wgpu = "24"
 bytemuck = { version = "1", features = ["derive"] }
 pollster = "0.4"
-taffy = "0.10.1"
 glyphon = "0.8"
 tokio = { version = "1", features = ["full"] }
+glam = "0.29"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/src/main.rs b/src/main.rs
index 763827c..bc5f065 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,18 +1,628 @@
 use std::sync::Arc;
+use std::time::Instant;
+use std::fs;
+use std::path::Path;
+
+use serde::Deserialize;
 
 use winit::application::ApplicationHandler;
-use winit::event::{ElementState, MouseButton, WindowEvent};
+use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent};
 use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
+use winit::keyboard::{Key, ModifiersState, NamedKey};
 use winit::window::{Window, WindowAttributes};
 
-use clear_ui::layout::Layout;
-use clear_ui::widget::{ContentBg, Header, Panel, Sidebar, StatusBar, Widget};
+use wgpu::util::DeviceExt;
+
+use clear_ui::widget::{Breadcrumb, Canvas, ContentBg, MenuBar, Node, ParametersBg, Splitter, StatusBar, TextLabel, ViewportBg, Widget};
+use clear_ui::colors;
 
 use glyphon::{
     Attrs, Buffer, Cache, FontSystem, Metrics, Resolution, SwashCache, TextArea, TextAtlas,
     TextBounds, TextRenderer, Viewport,
 };
 
+use glam::{Mat4, Vec3};
+
+const HEADER_IDX: usize = 0;
+const CONTENT_IDX: usize = 1;
+const SPLITTER1_IDX: usize = 2;
+const VIEWPORT_IDX: usize = 3;
+const SPLITTER2_IDX: usize = 4;
+const PARAM_IDX: usize = 5;
+const CANVAS_IDX: usize = 6;
+const LEFT_MENUBAR_IDX: usize = 7;
+const RIGHT_MENUBAR_IDX: usize = 8;
+const PARAM_MENUBAR_IDX: usize = 9;
+const NODE_SLOT_START: usize = 10;
+const NODE_SLOT_COUNT: usize = 32;
+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 HEADER_H: f32 = 40.0;
+const STATUS_H: f32 = 28.0;
+const MENUBAR_H: f32 = 26.0;
+const SPLITTER_W: f32 = 6.0;
+
+const MIN_COLUMN: f32 = 120.0;
+const BREADCRUMB_H: f32 = 24.0;
+
+#[derive(Clone, Deserialize)]
+struct ParamDef {
+    name: String,
+    #[serde(default)]
+    label: String,
+    #[serde(rename = "type")]
+    #[serde(default = "default_param_type")]
+    param_type: String,
+    #[serde(default)]
+    default: String,
+    #[serde(default)]
+    options: Vec<String>,
+}
+
+fn default_param_type() -> String { "string".to_string() }
+
+#[derive(Clone, Deserialize)]
+struct FsNode {
+    name: String,
+    #[serde(rename = "type")]
+    #[serde(default = "default_node_type")]
+    node_type: String,
+    #[serde(default)]
+    children: Vec<FsNode>,
+    #[serde(default)]
+    params: Vec<ParamDef>,
+    #[serde(skip)]
+    #[serde(default = "default_node_geometry_visible")]
+    geometry_visible: bool,
+}
+
+fn default_node_type() -> String { "node".to_string() }
+fn default_node_geometry_visible() -> bool { true }
+
+#[derive(Clone)]
+struct NodeTemplate {
+    label: String,
+    node: FsNode,
+}
+
+fn param_display(params: &[ParamDef]) -> Vec<(String, String)> {
+    params.iter().map(|p| {
+        let key = if p.label.is_empty() { &p.name } else { &p.label };
+        let value = if p.param_type == "choice" && !p.options.is_empty() && p.default.is_empty() {
+            p.options[0].clone()
+        } else {
+            p.default.clone()
+        };
+        (key.clone(), value)
+    }).collect()
+}
+
+fn flatten_node_templates(root: &FsNode) -> Vec<NodeTemplate> {
+    fn visit(node: &FsNode, path: &mut Vec<String>, out: &mut Vec<NodeTemplate>) {
+        if !node.name.is_empty() {
+            path.push(node.name.clone());
+            out.push(NodeTemplate { label: path.join(" / "), node: node.clone() });
+        }
+        for child in &node.children {
+            visit(child, path, out);
+        }
+        if !node.name.is_empty() {
+            path.pop();
+        }
+    }
+
+    let mut out = Vec::new();
+    let mut path = Vec::new();
+    for child in &root.children {
+        visit(child, &mut path, &mut out);
+    }
+    out
+}
+
+fn load_fs_tree() -> FsNode {
+    let nodes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("nodes");
+    let mut children = Vec::new();
+    if let Ok(entries) = fs::read_dir(&nodes_dir) {
+        let mut paths: Vec<_> = entries
+            .flatten()
+            .map(|e| e.path())
+            .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
+            .collect();
+        paths.sort();
+        for path in paths {
+            if let Ok(content) = fs::read_to_string(&path) {
+                if let Ok(node) = serde_json::from_str::<FsNode>(&content) {
+                    children.push(node);
+                }
+            }
+        }
+    }
+    FsNode {
+        name: String::new(),
+        node_type: default_node_type(),
+        children,
+        params: vec![],
+        geometry_visible: true,
+    }
+}
+
+struct ConfigDialog {
+    x: f32, y: f32, w: f32, h: f32,
+    hovered: bool,
+    visible: bool,
+    close_hovered: bool,
+    panel_w: f32,
+    panel_h: f32,
+    active_page: usize,
+    hovered_tab: Option<usize>,
+    grid_snap_enabled: bool,
+    grid_snap_pending: Option<bool>,
+    grid_snap_hovered: bool,
+    network_grid_enabled: bool,
+    network_grid_pending: Option<bool>,
+    network_grid_hovered: bool,
+    grid_size_x: f32,
+    grid_size_y: f32,
+    grid_size_pending: Option<(usize, f32)>,
+    hovered_spin_btn: Option<usize>,
+}
+
+impl ConfigDialog {
+    fn new() -> Self {
+        Self {
+            x: 0.0, y: 0.0, w: 0.0, h: 0.0,
+            hovered: false, visible: false,
+            close_hovered: false,
+            panel_w: 400.0, panel_h: 300.0,
+            active_page: 0,
+            hovered_tab: None,
+            grid_snap_enabled: true,
+            grid_snap_pending: None,
+            grid_snap_hovered: false,
+            network_grid_enabled: true,
+            network_grid_pending: None,
+            network_grid_hovered: false,
+            grid_size_x: 75.0,
+            grid_size_y: 75.0,
+            grid_size_pending: None,
+            hovered_spin_btn: None,
+        }
+    }
+
+    fn panel_rect(&self) -> (f32, f32, f32, f32) {
+        let px = self.x + (self.w - self.panel_w) / 2.0;
+        let py = self.y + (self.h - self.panel_h) / 2.0;
+        (px, py, self.panel_w, self.panel_h)
+    }
+
+    fn close_rect(&self, px: f32, pw: f32, py: f32) -> (f32, f32, f32, f32) {
+        (px + pw - 28.0, py + 8.0, 20.0, 20.0)
+    }
+
+    fn spin_btns(&self, px: f32, py: f32) -> [(usize, f32, f32); 4] {
+        let row0_y = py + 156.0; // Grid X
+        let row1_y = py + 178.0; // Grid Y
+        [
+            (0, px + 160.0, row0_y - 2.0),
+            (1, px + 210.0, row0_y - 2.0),
+            (2, px + 160.0, row1_y - 2.0),
+            (3, px + 210.0, row1_y - 2.0),
+        ]
+    }
+
+    fn tab_rects(&self, px: f32, py: f32) -> [(f32, f32, f32, f32); 3] {
+        let tab_y = py + 30.0;
+        let tab_h = 22.0;
+        let general_w = "General".len() as f32 * 7.5 + 16.0;
+        let network_w = "Network".len() as f32 * 7.5 + 16.0;
+        let bindings_w = "Bindings".len() as f32 * 7.5 + 16.0;
+        let gap = 4.0;
+        [
+            (px + 16.0, tab_y, general_w, tab_h),
+            (px + 16.0 + general_w + gap, tab_y, network_w, tab_h),
+            (px + 16.0 + general_w + gap + network_w + gap, tab_y, bindings_w, tab_h),
+        ]
+    }
+}
+
+impl Widget for ConfigDialog {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
+    fn set_hovered(&mut self, v: bool) { self.hovered = v; }
+    fn hovered(&self) -> bool { self.hovered }
+
+    fn set_visible(&mut self, v: bool) { self.visible = v; }
+    fn visible(&self) -> bool { self.visible }
+
+    fn set_config_toggle(&mut self, id: usize, val: bool) {
+        match id {
+            0 => self.grid_snap_enabled = val,
+            1 => self.network_grid_enabled = val,
+            _ => {}
+        }
+    }
+    fn take_config_toggle(&mut self) -> Option<(usize, bool)> {
+        if let Some(v) = self.grid_snap_pending.take() {
+            Some((0, v))
+        } else if let Some(v) = self.network_grid_pending.take() {
+            Some((1, v))
+        } else {
+            None
+        }
+    }
+
+    fn set_config_spin(&mut self, id: usize, val: f32) {
+        match id {
+            0 => self.grid_size_x = val,
+            1 => self.grid_size_y = val,
+            _ => {}
+        }
+    }
+    fn take_config_spin(&mut self) -> Option<(usize, f32)> {
+        self.grid_size_pending.take()
+    }
+
+    fn hit_test(&self, px: f32, py: f32) -> bool {
+        if !self.visible { return false; }
+        px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
+    }
+
+    fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+        let was = self.hovered;
+        self.hovered = self.hit_test(px, py);
+        let (ppx, ppy, pw, _ph) = self.panel_rect();
+        let old_close = self.close_hovered;
+        let (cx, cy, cw, ch) = self.close_rect(ppx, pw, ppy);
+        self.close_hovered = self.visible && px >= cx && px < cx + cw && py >= cy && py < cy + ch;
+
+        let old_tab = self.hovered_tab;
+        self.hovered_tab = None;
+        if self.visible {
+            for (i, (tx, ty, tw, th)) in self.tab_rects(ppx, ppy).iter().enumerate() {
+                if px >= *tx && px < *tx + *tw && py >= *ty && py < *ty + *th {
+                    self.hovered_tab = Some(i);
+                    break;
+                }
+            }
+        }
+
+        let old_tg = self.grid_snap_hovered;
+        let old_ng = self.network_grid_hovered;
+        let old_sb = self.hovered_spin_btn;
+        self.grid_snap_hovered = false;
+        self.network_grid_hovered = false;
+        self.hovered_spin_btn = None;
+        if self.visible && self.active_page == 1 {
+            let row1_y = ppy + 84.0;
+            self.grid_snap_hovered = px >= ppx + 16.0 && px < ppx + 200.0
+                && py >= row1_y - 2.0 && py < row1_y + 18.0;
+            let row2_y = ppy + 108.0;
+            self.network_grid_hovered = px >= ppx + 16.0 && px < ppx + 200.0
+                && py >= row2_y - 2.0 && py < row2_y + 18.0;
+            for &(btn_id, bx, by) in &self.spin_btns(ppx, ppy) {
+                if px >= bx && px < bx + 22.0 && py >= by && py < by + 20.0 {
+                    self.hovered_spin_btn = Some(btn_id);
+                    break;
+                }
+            }
+        }
+
+        was != self.hovered || old_close != self.close_hovered || old_tab != self.hovered_tab
+            || old_tg != self.grid_snap_hovered || old_ng != self.network_grid_hovered
+            || old_sb != self.hovered_spin_btn
+    }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+        if !self.visible || button != MouseButton::Left || state != ElementState::Pressed { return false; }
+        let (ppx, ppy, pw, ph) = self.panel_rect();
+        let in_panel = px >= ppx && px <= ppx + pw && py >= ppy && py <= ppy + ph;
+        let (cx, cy, cw, ch) = self.close_rect(ppx, pw, ppy);
+        let on_close = px >= cx && px < cx + cw && py >= cy && py < cy + ch;
+        if !in_panel || on_close {
+            self.visible = false;
+            return true;
+        }
+        for (i, (tx, ty, tw, th)) in self.tab_rects(ppx, ppy).iter().enumerate() {
+            if px >= *tx && px < *tx + *tw && py >= *ty && py < *ty + *th {
+                self.active_page = i;
+                return true;
+            }
+        }
+        if self.active_page == 1 {
+            let row1_y = ppy + 84.0;
+            if px >= ppx + 16.0 && px < ppx + 200.0
+                && py >= row1_y - 2.0 && py < row1_y + 18.0
+            {
+                self.grid_snap_enabled = !self.grid_snap_enabled;
+                self.grid_snap_pending = Some(self.grid_snap_enabled);
+                return true;
+            }
+            let row2_y = ppy + 108.0;
+            if px >= ppx + 16.0 && px < ppx + 200.0
+                && py >= row2_y - 2.0 && py < row2_y + 18.0
+            {
+                self.network_grid_enabled = !self.network_grid_enabled;
+                self.network_grid_pending = Some(self.network_grid_enabled);
+                return true;
+            }
+            for &(btn_id, bx, by) in &self.spin_btns(ppx, ppy) {
+                if px >= bx && px < bx + 22.0 && py >= by && py < by + 20.0 {
+                    let (val, delta) = match btn_id {
+                        0 => (&mut self.grid_size_x, -5.0),
+                        1 => (&mut self.grid_size_x, 5.0),
+                        2 => (&mut self.grid_size_y, -5.0),
+                        3 => (&mut self.grid_size_y, 5.0),
+                        _ => unreachable!(),
+                    };
+                    let new = (*val + delta).clamp(5.0, 100.0);
+                    if (new - *val).abs() > 0.01 {
+                        *val = new;
+                        let id = if btn_id < 2 { 0 } else { 1 };
+                        self.grid_size_pending = Some((id, *val));
+                    }
+                    return true;
+                }
+            }
+        }
+        false
+    }
+
+    fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
+        if !self.visible { return false; }
+        if event.state == ElementState::Pressed && event.logical_key == Key::Named(NamedKey::Escape) {
+            self.visible = false;
+            return true;
+        }
+        false
+    }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        if !self.visible { return vec![]; }
+        let mut quads = Vec::new();
+        quads.push((self.x, self.y, self.w, self.h, [0.0, 0.0, 0.0, 0.5]));
+        let (px, py, pw, ph) = self.panel_rect();
+        quads.push((px, py, pw, ph, colors::PANEL_MENU_BG));
+
+        // Close button hover
+        if self.close_hovered {
+            let (cx, cy, cw, ch) = self.close_rect(px, pw, py);
+            quads.push((cx, cy, cw, ch, colors::PANEL_MENU_HOVER));
+        }
+
+        // Tab bar background
+        let tab_y = py + 30.0;
+        let tab_h = 22.0;
+        quads.push((px + 8.0, tab_y, pw - 16.0, tab_h, [0.15, 0.15, 0.20, 1.0]));
+
+        // Tabs
+        let tabs = self.tab_rects(px, py);
+        for (i, &(tx, ty, tw, th)) in tabs.iter().enumerate() {
+            let bg = if i == self.active_page {
+                colors::PANEL_MENU_BG
+            } else if Some(i) == self.hovered_tab {
+                [0.22, 0.22, 0.30, 1.0]
+            } else {
+                [0.18, 0.18, 0.25, 1.0]
+            };
+            quads.push((tx, ty, tw, th, bg));
+        }
+
+        // Toggle and spin hover highlights on Network page
+        if self.visible && self.active_page == 1 {
+            if self.grid_snap_hovered {
+                let row1_y = py + 84.0;
+                quads.push((px + 14.0, row1_y - 2.0, 186.0, 20.0, [0.25, 0.25, 0.35, 0.4]));
+            }
+            if self.network_grid_hovered {
+                let row2_y = py + 108.0;
+                quads.push((px + 14.0, row2_y - 2.0, 186.0, 20.0, [0.25, 0.25, 0.35, 0.4]));
+            }
+            if let Some(sb) = self.hovered_spin_btn {
+                for &(btn_id, bx, by) in &self.spin_btns(px, py) {
+                    if btn_id == sb {
+                        quads.push((bx, by, 22.0, 20.0, [0.35, 0.35, 0.45, 0.5]));
+                        break;
+                    }
+                }
+            }
+        }
+
+        quads
+    }
+
+    fn text_labels(&self) -> Vec<TextLabel> {
+        if !self.visible { return vec![]; }
+        let (px, py, pw, _) = self.panel_rect();
+        let mut labels = Vec::new();
+
+        // Title and close button
+        labels.push(TextLabel { text: "Configure Clear Designer".into(), x: px + 16.0, y: py + 10.0, font_size: 14.0, color: [0xcc, 0xcc, 0xd4] });
+        labels.push(TextLabel { text: "\u{2715}".into(), x: px + pw - 22.0, y: py + 10.0, font_size: 14.0, color: [0xaa, 0xaa, 0xbb] });
+
+        // Tab labels
+        let tabs = self.tab_rects(px, py);
+        let tab_labels = ["General", "Network", "Bindings"];
+        for (i, &(tx, ty, _, _)) in tabs.iter().enumerate() {
+            let color = if i == self.active_page { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] };
+            labels.push(TextLabel {
+                text: tab_labels[i].into(),
+                x: tx + 8.0,
+                y: ty + 4.0,
+                font_size: 12.0,
+                color,
+            });
+        }
+
+        // Page content
+        let content_y = py + 60.0;
+        if self.active_page == 0 {
+            labels.push(TextLabel { text: "General settings for Clear Designer".into(), x: px + 16.0, y: content_y, font_size: 12.0, color: [0x88, 0x88, 0x99] });
+        } else if self.active_page == 1 {
+            labels.push(TextLabel { text: "Network Configuration".into(), x: px + 16.0, y: content_y, font_size: 13.0, color: [0xcc, 0xcc, 0xd4] });
+            let snap_text = if self.grid_snap_enabled { "[\u{2713}] Snap to Grid" } else { "[ ] Snap to Grid" };
+            labels.push(TextLabel { text: snap_text.into(), x: px + 20.0, y: content_y + 26.0, font_size: 12.0, color: [0xaa, 0xaa, 0xbb] });
+            let grid_text = if self.network_grid_enabled { "[\u{2713}] Show Grid" } else { "[ ] Show Grid" };
+            labels.push(TextLabel { text: grid_text.into(), x: px + 20.0, y: content_y + 50.0, font_size: 12.0, color: [0xaa, 0xaa, 0xbb] });
+            labels.push(TextLabel { text: "Grid Spacing".into(), x: px + 16.0, y: content_y + 78.0, font_size: 12.0, color: [0x88, 0x88, 0x99] });
+            let gx = self.grid_size_x as i32;
+            labels.push(TextLabel { text: "Grid X:".into(), x: px + 16.0, y: content_y + 100.0, font_size: 12.0, color: [0xbb, 0xbb, 0xcc] });
+            labels.push(TextLabel { text: "\u{2212}".into(), x: px + 170.0, y: content_y + 100.0, font_size: 12.0, color: [0xcc, 0xcc, 0xd4] });
+            labels.push(TextLabel { text: format!("{}", gx), x: px + 188.0, y: content_y + 100.0, font_size: 12.0, color: [0xdd, 0xdd, 0x88] });
+            labels.push(TextLabel { text: "+".into(), x: px + 220.0, y: content_y + 100.0, font_size: 12.0, color: [0xcc, 0xcc, 0xd4] });
+            let gy = self.grid_size_y as i32;
+            labels.push(TextLabel { text: "Grid Y:".into(), x: px + 16.0, y: content_y + 122.0, font_size: 12.0, color: [0xbb, 0xbb, 0xcc] });
+            labels.push(TextLabel { text: "\u{2212}".into(), x: px + 170.0, y: content_y + 122.0, font_size: 12.0, color: [0xcc, 0xcc, 0xd4] });
+            labels.push(TextLabel { text: format!("{}", gy), x: px + 188.0, y: content_y + 122.0, font_size: 12.0, color: [0xdd, 0xdd, 0x88] });
+            labels.push(TextLabel { text: "+".into(), x: px + 220.0, y: content_y + 122.0, font_size: 12.0, color: [0xcc, 0xcc, 0xd4] });
+        } else {
+            labels.push(TextLabel { text: "Keyboard Shortcuts".into(), x: px + 16.0, y: content_y, font_size: 13.0, color: [0xcc, 0xcc, 0xd4] });
+            let shortcuts = [
+                ("Ctrl+G", "Toggle Grid (viewport)"),
+                ("Ctrl+E", "Toggle Cube"),
+                ("Ctrl+A", "Square Viewport Aspect"),
+                ("Ctrl+,", "Configure Dialog"),
+            ];
+            for (i, (key, desc)) in shortcuts.iter().enumerate() {
+                let y = content_y + 28.0 + i as f32 * 22.0;
+                labels.push(TextLabel { text: (*key).into(), x: px + 32.0, y, font_size: 12.0, color: [0xdd, 0xdd, 0x88] });
+                labels.push(TextLabel { text: (*desc).into(), x: px + 130.0, y, font_size: 12.0, color: [0xaa, 0xaa, 0xbb] });
+            }
+        }
+
+        labels
+    }
+
+    fn take_click(&mut self) -> bool {
+        if !self.visible { return false; }
+        true
+    }
+}
+
+struct NodePalette {
+    x: f32, y: f32, w: f32, h: f32,
+    visible: bool,
+    query: String,
+    items: Vec<String>,
+    selected: usize,
+}
+
+impl NodePalette {
+    fn new() -> Self {
+        Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, visible: false, query: String::new(), items: Vec::new(), selected: 0 }
+    }
+
+    fn panel_rect(&self) -> (f32, f32, f32, f32) {
+        let pw = 460.0_f32.min(self.w - 40.0).max(280.0);
+        let ph = 360.0_f32.min(self.h - 80.0).max(240.0);
+        (self.x + (self.w - pw) / 2.0, self.y + 76.0, pw, ph)
+    }
+}
+
+impl Widget for NodePalette {
+    fn rect(&self) -> (f32, f32, f32, f32) { (self.x, self.y, self.w, self.h) }
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { self.x = x; self.y = y; self.w = w; self.h = h; }
+    fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
+    fn hit_test(&self, px: f32, py: f32) -> bool {
+        self.visible && px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
+    }
+    fn set_visible(&mut self, visible: bool) { self.visible = visible; }
+    fn visible(&self) -> bool { self.visible }
+    fn take_click(&mut self) -> bool { self.visible }
+    fn set_palette_state(&mut self, visible: bool, query: &str, items: &[String], selected: usize) {
+        self.visible = visible;
+        self.query = query.to_string();
+        self.items = items.to_vec();
+        self.selected = selected;
+    }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        if !self.visible { return Vec::new(); }
+        let mut quads = Vec::new();
+        quads.push((self.x, self.y, self.w, self.h, [0.0, 0.0, 0.0, 0.45]));
+        let (px, py, pw, ph) = self.panel_rect();
+        quads.push((px, py, pw, ph, colors::PANEL_MENU_BG));
+        quads.push((px + 16.0, py + 48.0, pw - 32.0, 32.0, [0.10, 0.10, 0.14, 1.0]));
+        let list_y = py + 92.0;
+        let row_h = 24.0;
+        let visible_rows = ((ph - 120.0) / row_h).floor().max(0.0) as usize;
+        let start = self.selected.saturating_sub(visible_rows.saturating_sub(1));
+        for (row, item_idx) in (start..self.items.len().min(start + visible_rows)).enumerate() {
+            let y = list_y + row as f32 * row_h;
+            let bg = if item_idx == self.selected { [0.24, 0.33, 0.55, 0.85] } else { [0.14, 0.14, 0.19, 0.55] };
+            quads.push((px + 16.0, y, pw - 32.0, row_h - 2.0, bg));
+        }
+        quads
+    }
+
+    fn text_labels(&self) -> Vec<TextLabel> {
+        if !self.visible { return Vec::new(); }
+        let (px, py, _pw, ph) = self.panel_rect();
+        let mut labels = Vec::new();
+        labels.push(TextLabel { text: "Add Node".into(), x: px + 16.0, y: py + 14.0, font_size: 15.0, color: [0xdd, 0xdd, 0xe6] });
+        labels.push(TextLabel { text: "Type to search, Enter to place, Esc to close".into(), x: px + 100.0, y: py + 17.0, font_size: 11.0, color: [0x88, 0x88, 0x99] });
+        let query = if self.query.is_empty() { "Search nodes...".to_string() } else { self.query.clone() };
+        let query_color = if self.query.is_empty() { [0x66, 0x66, 0x77] } else { [0xdd, 0xdd, 0xe6] };
+        labels.push(TextLabel { text: query, x: px + 28.0, y: py + 57.0, font_size: 13.0, color: query_color });
+        let row_h = 24.0;
+        let visible_rows = ((ph - 120.0) / row_h).floor().max(0.0) as usize;
+        if self.items.is_empty() {
+            labels.push(TextLabel { text: "No matching nodes".into(), x: px + 28.0, y: py + 100.0, font_size: 12.0, color: [0xaa, 0xaa, 0xbb] });
+        } else {
+            let start = self.selected.saturating_sub(visible_rows.saturating_sub(1));
+            for (row, item_idx) in (start..self.items.len().min(start + visible_rows)).enumerate() {
+                labels.push(TextLabel {
+                    text: self.items[item_idx].clone(),
+                    x: px + 28.0,
+                    y: py + 98.0 + row as f32 * row_h,
+                    font_size: 12.0,
+                    color: if item_idx == self.selected { [0xff, 0xff, 0xdd] } else { [0xcc, 0xcc, 0xd8] },
+                });
+            }
+        }
+        labels
+    }
+}
+
+#[derive(Clone, Copy, PartialEq)]
+enum Action {
+    ToggleGrid,
+    ToggleCube,
+    ToggleSquareViewport,
+    ToggleConfigure,
+}
+
+struct KeyBind {
+    ctrl: bool,
+    shift: bool,
+    alt: bool,
+    super_: bool,
+    key: Key,
+    action: Action,
+}
+
+impl KeyBind {
+    fn matches(&self, mods: &ModifiersState, key: &Key) -> bool {
+        mods.control_key() == self.ctrl
+            && mods.shift_key() == self.shift
+            && mods.alt_key() == self.alt
+            && mods.super_key() == self.super_
+            && key == &self.key
+    }
+}
+
+fn default_keybinds() -> Vec<KeyBind> {
+    vec![
+        KeyBind { ctrl: true, shift: false, alt: false, super_: false, key: Key::Character("g".into()), action: Action::ToggleGrid },
+        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 },
+    ]
+}
+
 #[repr(C)]
 #[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
 struct Vertex {
@@ -21,10 +631,7 @@ struct Vertex {
 }
 
 impl Vertex {
-    const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![
-        0 => Float32x2,
-        1 => Float32x4,
-    ];
+    const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4];
 
     fn desc() -> wgpu::VertexBufferLayout<'static> {
         wgpu::VertexBufferLayout {
@@ -35,6 +642,154 @@ impl Vertex {
     }
 }
 
+#[repr(C)]
+#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+struct Vertex3D {
+    position: [f32; 3],
+    color: [f32; 3],
+}
+
+impl Vertex3D {
+    const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3];
+
+    fn desc() -> wgpu::VertexBufferLayout<'static> {
+        wgpu::VertexBufferLayout {
+            array_stride: std::mem::size_of::<Self>() as wgpu::BufferAddress,
+            step_mode: wgpu::VertexStepMode::Vertex,
+            attributes: &Self::ATTRIBS,
+        }
+    }
+}
+
+fn cube_vertices() -> Vec<Vertex3D> {
+    let s = 0.5;
+    let data: &[([f32; 3], [f32; 3])] = &[
+        ([-s, -s, s], [0.8, 0.2, 0.2]), ([s, -s, s], [0.8, 0.2, 0.2]), ([s, s, s], [0.8, 0.2, 0.2]),
+        ([-s, -s, s], [0.8, 0.2, 0.2]), ([s, s, s], [0.8, 0.2, 0.2]), ([-s, s, s], [0.8, 0.2, 0.2]),
+        ([s, -s, -s], [0.2, 0.8, 0.2]), ([-s, -s, -s], [0.2, 0.8, 0.2]), ([-s, s, -s], [0.2, 0.8, 0.2]),
+        ([s, -s, -s], [0.2, 0.8, 0.2]), ([-s, s, -s], [0.2, 0.8, 0.2]), ([s, s, -s], [0.2, 0.8, 0.2]),
+        ([-s, s, s], [0.2, 0.2, 0.8]), ([s, s, s], [0.2, 0.2, 0.8]), ([s, s, -s], [0.2, 0.2, 0.8]),
+        ([-s, s, s], [0.2, 0.2, 0.8]), ([s, s, -s], [0.2, 0.2, 0.8]), ([-s, s, -s], [0.2, 0.2, 0.8]),
+        ([-s, -s, -s], [0.8, 0.8, 0.2]), ([s, -s, -s], [0.8, 0.8, 0.2]), ([s, -s, s], [0.8, 0.8, 0.2]),
+        ([-s, -s, -s], [0.8, 0.8, 0.2]), ([s, -s, s], [0.8, 0.8, 0.2]), ([-s, -s, s], [0.8, 0.8, 0.2]),
+        ([s, -s, s], [0.8, 0.2, 0.8]), ([s, -s, -s], [0.8, 0.2, 0.8]), ([s, s, -s], [0.8, 0.2, 0.8]),
+        ([s, -s, s], [0.8, 0.2, 0.8]), ([s, s, -s], [0.8, 0.2, 0.8]), ([s, s, s], [0.8, 0.2, 0.8]),
+        ([-s, -s, -s], [0.2, 0.8, 0.8]), ([-s, -s, s], [0.2, 0.8, 0.8]), ([-s, s, s], [0.2, 0.8, 0.8]),
+        ([-s, -s, -s], [0.2, 0.8, 0.8]), ([-s, s, s], [0.2, 0.8, 0.8]), ([-s, s, -s], [0.2, 0.8, 0.8]),
+    ];
+    data.iter().map(|&(p, c)| Vertex3D { position: p, color: c }).collect()
+}
+
+fn sphere_vertices(center: Vec3, radius: f32) -> Vec<Vertex3D> {
+    let lat_steps = 16;
+    let lon_steps = 24;
+    let mut verts = Vec::new();
+
+    for lat in 0..lat_steps {
+        let theta0 = std::f32::consts::PI * lat as f32 / lat_steps as f32;
+        let theta1 = std::f32::consts::PI * (lat + 1) as f32 / lat_steps as f32;
+        for lon in 0..lon_steps {
+            let phi0 = std::f32::consts::TAU * lon as f32 / lon_steps as f32;
+            let phi1 = std::f32::consts::TAU * (lon + 1) as f32 / lon_steps as f32;
+            let p00 = sphere_point(center, radius, theta0, phi0);
+            let p10 = sphere_point(center, radius, theta1, phi0);
+            let p11 = sphere_point(center, radius, theta1, phi1);
+            let p01 = sphere_point(center, radius, theta0, phi1);
+            verts.push(sphere_vertex(center, p00));
+            verts.push(sphere_vertex(center, p10));
+            verts.push(sphere_vertex(center, p11));
+            verts.push(sphere_vertex(center, p00));
+            verts.push(sphere_vertex(center, p11));
+            verts.push(sphere_vertex(center, p01));
+        }
+    }
+
+    verts
+}
+
+fn sphere_point(center: Vec3, radius: f32, theta: f32, phi: f32) -> Vec3 {
+    center + Vec3::new(
+        radius * theta.sin() * phi.cos(),
+        radius * theta.cos(),
+        radius * theta.sin() * phi.sin(),
+    )
+}
+
+fn sphere_vertex(center: Vec3, point: Vec3) -> Vertex3D {
+    let n = (point - center).normalize_or_zero();
+    Vertex3D {
+        position: point.to_array(),
+        color: [0.35 + n.x.abs() * 0.35, 0.45 + n.y.abs() * 0.35, 0.85],
+    }
+}
+
+fn node_param_f32(node: &FsNode, name: &str, fallback: f32) -> f32 {
+    node.params.iter()
+        .find(|p| p.name.eq_ignore_ascii_case(name))
+        .and_then(|p| p.default.parse::<f32>().ok())
+        .unwrap_or(fallback)
+}
+
+fn network_sphere_vertices(root: &FsNode) -> Vec<Vertex3D> {
+    fn visit(node: &FsNode, count: &mut usize, out: &mut Vec<Vertex3D>) {
+        if node.node_type.eq_ignore_ascii_case("sphere") {
+            let idx = *count;
+            *count += 1;
+            if node.geometry_visible {
+                let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
+                out.extend(sphere_vertices(center, node_param_f32(node, "Radius", 0.5).max(0.05)));
+            }
+        }
+        for child in &node.children {
+            visit(child, count, out);
+        }
+    }
+
+    let mut out = Vec::new();
+    let mut count = 0;
+    for child in &root.children {
+        visit(child, &mut count, &mut out);
+    }
+    out
+}
+
+fn grid_vertices() -> Vec<Vertex3D> {
+    let half_w = 0.015;
+    let color = [0.35, 0.35, 0.40];
+    let range = 4.0;
+    let step = 1.0;
+    let y = 0.0;
+    let mut verts = Vec::new();
+
+    let mut z = -range;
+    while z <= range {
+        let z0 = z - half_w;
+        let z1 = z + half_w;
+        verts.push(Vertex3D { position: [-range, y, z0], color });
+        verts.push(Vertex3D { position: [range, y, z0], color });
+        verts.push(Vertex3D { position: [-range, y, z1], color });
+        verts.push(Vertex3D { position: [range, y, z0], color });
+        verts.push(Vertex3D { position: [range, y, z1], color });
+        verts.push(Vertex3D { position: [-range, y, z1], color });
+        z += step;
+    }
+
+    let mut x = -range;
+    while x <= range {
+        let x0 = x - half_w;
+        let x1 = x + half_w;
+        verts.push(Vertex3D { position: [x0, y, -range], color });
+        verts.push(Vertex3D { position: [x1, y, -range], color });
+        verts.push(Vertex3D { position: [x0, y, range], color });
+        verts.push(Vertex3D { position: [x1, y, -range], color });
+        verts.push(Vertex3D { position: [x1, y, range], color });
+        verts.push(Vertex3D { position: [x0, y, range], color });
+        x += step;
+    }
+
+    verts
+}
+
 fn quad_vertices(
     x: f32, y: f32, w: f32, h: f32,
     surface_w: f32, surface_h: f32,
@@ -44,7 +799,6 @@ fn quad_vertices(
     let y0 = 1.0 - (y / surface_h) * 2.0;
     let x1 = ((x + w) / surface_w) * 2.0 - 1.0;
     let y1 = 1.0 - ((y + h) / surface_h) * 2.0;
-
     [
         Vertex { position: [x0, y0], color },
         Vertex { position: [x1, y0], color },
@@ -60,6 +814,28 @@ fn widget_vertices(w: &dyn Widget, sw: f32, sh: f32) -> Vec<Vertex> {
     quad_vertices(x, y, ww, h, sw, sh, w.color()).to_vec()
 }
 
+fn quad_vertices_clipped(
+    x: f32, y: f32, w: f32, h: f32,
+    surface_w: f32, surface_h: f32,
+    color: [f32; 4],
+    clip: (f32, f32, f32, f32),
+) -> Vec<Vertex> {
+    let (cx0, cy0, cx1, cy1) = clip;
+    let ix0 = x.max(cx0);
+    let iy0 = y.max(cy0);
+    let ix1 = (x + w).min(cx1);
+    let iy1 = (y + h).min(cy1);
+    if ix1 <= ix0 || iy1 <= iy0 {
+        return Vec::new();
+    }
+    quad_vertices(ix0, iy0, ix1 - ix0, iy1 - iy0, surface_w, surface_h, color).to_vec()
+}
+
+fn widget_vertices_clipped(w: &dyn Widget, sw: f32, sh: f32, clip: (f32, f32, f32, f32)) -> Vec<Vertex> {
+    let (x, y, ww, h) = w.rect();
+    quad_vertices_clipped(x, y, ww, h, sw, sh, w.color(), clip)
+}
+
 fn make_text_buffer(font_system: &mut FontSystem, text: &str, size: f32) -> Buffer {
     let metrics = Metrics::new(size, size * 1.4);
     let mut buffer = Buffer::new(font_system, metrics);
@@ -78,8 +854,40 @@ struct State {
     vertex_buffer: wgpu::Buffer,
     vertex_count: u32,
 
+    pipeline_3d: wgpu::RenderPipeline,
+    bind_group_3d: wgpu::BindGroup,
+    bind_group_layout_3d: wgpu::BindGroupLayout,
+    uniform_buffer: wgpu::Buffer,
+    vertex_buffer_3d: wgpu::Buffer,
+    vertex_count_3d: u32,
+    vertex_buffer_spheres: wgpu::Buffer,
+    vertex_count_spheres: u32,
+    vertex_buffer_grid: wgpu::Buffer,
+    vertex_count_grid: u32,
+    depth_texture: wgpu::Texture,
+    depth_texture_view: wgpu::TextureView,
+    rotation: f32,
+    show_grid: bool,
+    show_cube: bool,
+
+    fs_root: FsNode,
+    node_templates: Vec<NodeTemplate>,
+    current_path: Vec<usize>,
+    last_click: Option<(Instant, usize)>,
+
+    keybinds: Vec<KeyBind>,
+    pending_action: Option<Action>,
+
     widgets: Vec<Box<dyn Widget>>,
-    layout: Layout,
+    positions: Vec<(f32, f32, f32, f32)>,
+    node_slots: Vec<usize>,
+    splitter1_x: f32,
+    splitter2_x: f32,
+    left_offsets: Vec<(f32, f32)>,
+    node_palette_visible: bool,
+    node_palette_query: String,
+    node_palette_filtered: Vec<usize>,
+    node_palette_selected: usize,
 
     font_system: FontSystem,
     swash_cache: SwashCache,
@@ -87,23 +895,249 @@ struct State {
     text_renderer: TextRenderer,
     text_viewport: Viewport,
 
-    title_buffer: Buffer,
     status_buffer: Buffer,
 
     drag_widget: Option<usize>,
+    focused_widget: Option<usize>,
 
     cursor_x: f32,
     cursor_y: f32,
+    grid_cursor_col: i32,
+    grid_cursor_row: i32,
+    modifiers: ModifiersState,
 
-    width: u32,
-    height: u32,
+    width: f32,
+    height: f32,
+    physical_width: u32,
+    physical_height: u32,
+    scale: f64,
+    square_viewport: bool,
+    grid_snap_enabled: bool,
+    network_grid_visible: bool,
+    grid_size_x: f32,
+    grid_size_y: f32,
+
+    pan_x: f32,
+    pan_y: f32,
+    is_panning: bool,
+    pan_start_cx: f32,
+    pan_start_cy: f32,
+    pan_start_x: f32,
+    pan_start_y: f32,
+    space_pressed: bool,
 }
 
 impl State {
+    fn body_h(&self) -> f32 { self.height - HEADER_H - STATUS_H }
+
+    fn content_left_w(&self) -> f32 { self.splitter1_x }
+
+    fn content_right_x(&self) -> f32 { self.splitter1_x + SPLITTER_W }
+
+    fn viewport_w(&self) -> f32 { self.splitter2_x - self.content_right_x() }
+
+    fn param_x(&self) -> f32 { self.splitter2_x + SPLITTER_W }
+
+    fn param_w(&self) -> f32 { self.width - self.param_x() }
+
+    fn clamp_splitters(&mut self) {
+        let min_s1 = MIN_COLUMN;
+        let max_s1 = self.splitter2_x - SPLITTER_W - MIN_COLUMN;
+        self.splitter1_x = self.splitter1_x.clamp(min_s1, max_s1);
+        let min_s2 = self.splitter1_x + SPLITTER_W + MIN_COLUMN;
+        let max_s2 = self.width - SPLITTER_W - MIN_COLUMN;
+        self.splitter2_x = self.splitter2_x.clamp(min_s2, max_s2);
+    }
+
+    fn current_dir(&self) -> &FsNode {
+        let mut node = &self.fs_root;
+        for &i in &self.current_path {
+            node = &node.children[i];
+        }
+        node
+    }
+
+    fn current_dir_mut(&mut self) -> &mut FsNode {
+        let mut node = &mut self.fs_root;
+        for &i in &self.current_path {
+            node = &mut node.children[i];
+        }
+        node
+    }
+
+    fn current_path_names(&self) -> Vec<String> {
+        let mut node = &self.fs_root;
+        let mut names = Vec::new();
+        for &i in &self.current_path {
+            if let Some(child) = node.children.get(i) {
+                names.push(child.name.clone());
+                node = child;
+            }
+        }
+        names
+    }
+
+    fn refresh_node_palette(&mut self) {
+        let q = self.node_palette_query.to_lowercase();
+        self.node_palette_filtered = self.node_templates.iter().enumerate()
+            .filter_map(|(i, t)| {
+                if q.is_empty() || t.label.to_lowercase().contains(&q) { Some(i) } else { None }
+            })
+            .collect();
+        if self.node_palette_selected >= self.node_palette_filtered.len() {
+            self.node_palette_selected = self.node_palette_filtered.len().saturating_sub(1);
+        }
+        let items: Vec<String> = self.node_palette_filtered.iter()
+            .map(|&i| self.node_templates[i].label.clone())
+            .collect();
+        self.widgets[NODE_PALETTE_IDX].set_palette_state(
+            self.node_palette_visible,
+            &self.node_palette_query,
+            &items,
+            self.node_palette_selected,
+        );
+    }
+
+    fn open_node_palette(&mut self) {
+        self.node_palette_visible = true;
+        self.node_palette_query.clear();
+        self.node_palette_selected = 0;
+        self.refresh_node_palette();
+        self.upload_vertices();
+    }
+
+    fn close_node_palette(&mut self) {
+        self.node_palette_visible = false;
+        self.refresh_node_palette();
+        self.upload_vertices();
+    }
+
+    fn next_node_offset(&self, _idx: usize) -> (f32, f32) {
+        (self.grid_cursor_col as f32, self.grid_cursor_row as f32)
+    }
+
+    fn place_selected_node(&mut self) -> bool {
+        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();
+        self.current_dir_mut().children.push(node);
+        self.left_offsets[child_idx] = self.next_node_offset(child_idx);
+        self.close_node_palette();
+        self.sync_nodes();
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+        self.upload_vertices();
+        true
+    }
+
+    fn handle_node_palette_key(&mut self, event: &KeyEvent) -> bool {
+        if event.state != ElementState::Pressed { return false; }
+        match &event.logical_key {
+            Key::Named(NamedKey::Escape) => {
+                self.close_node_palette();
+                true
+            }
+            Key::Named(NamedKey::Enter) => self.place_selected_node(),
+            Key::Named(NamedKey::ArrowDown) => {
+                if !self.node_palette_filtered.is_empty() {
+                    self.node_palette_selected = (self.node_palette_selected + 1).min(self.node_palette_filtered.len() - 1);
+                    self.refresh_node_palette();
+                    self.upload_vertices();
+                }
+                true
+            }
+            Key::Named(NamedKey::ArrowUp) => {
+                if self.node_palette_selected > 0 {
+                    self.node_palette_selected -= 1;
+                    self.refresh_node_palette();
+                    self.upload_vertices();
+                }
+                true
+            }
+            Key::Named(NamedKey::Backspace) => {
+                self.node_palette_query.pop();
+                self.refresh_node_palette();
+                self.upload_vertices();
+                true
+            }
+            Key::Named(NamedKey::Tab) => {
+                self.close_node_palette();
+                true
+            }
+            _ => {
+                if let Some(text) = &event.text {
+                    for ch in text.chars().filter(|c| !c.is_control()) {
+                        self.node_palette_query.push(ch);
+                    }
+                    self.refresh_node_palette();
+                    self.upload_vertices();
+                    true
+                } else {
+                    false
+                }
+            }
+        }
+    }
+
+    fn sync_nodes(&mut self) {
+        let names: Vec<Option<String>> = {
+            let dir = self.current_dir();
+            self.node_slots.iter().enumerate().map(|(i, _)| {
+                dir.children.get(i).map(|c| c.name.clone())
+            }).collect()
+        };
+        let params_list: Vec<Vec<(String, String)>> = {
+            let dir = self.current_dir();
+            self.node_slots.iter().enumerate().map(|(i, _)| {
+                dir.children.get(i).map(|c| param_display(&c.params)).unwrap_or_default()
+            }).collect()
+        };
+        let geom_visibles: Vec<bool> = {
+            let dir = self.current_dir();
+            self.node_slots.iter().enumerate().map(|(i, _)| {
+                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] {
+                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]);
+            } else {
+                self.widgets[slot].set_node_name("");
+                self.widgets[slot].set_display_params(&[]);
+                self.widgets[slot].set_geom_visible(true);
+            }
+        }
+        let path_strs = self.current_path_names();
+        self.widgets[BREADCRUMB_IDX].set_path(&path_strs);
+    }
+
+    fn create_depth_texture(&self) -> (wgpu::Texture, wgpu::TextureView) {
+        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("Depth Texture"),
+            size: wgpu::Extent3d { width: self.physical_width.max(1), height: self.physical_height.max(1), depth_or_array_layers: 1 },
+            mip_level_count: 1,
+            sample_count: 1,
+            dimension: wgpu::TextureDimension::D2,
+            format: wgpu::TextureFormat::Depth32Float,
+            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+            view_formats: &[],
+        });
+        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
+        (tex, view)
+    }
+
     async fn new(window: Arc<Window>) -> Self {
-        let size = window.inner_size();
-        let sw = size.width as f32;
-        let sh = size.height as f32;
+        let scale = window.scale_factor();
+        let physical_size = window.inner_size();
+        let pw = physical_size.width.max(1);
+        let ph = physical_size.height.max(1);
+        let lw = pw as f32 / scale as f32;
+        let lh = ph as f32 / scale as f32;
+        let sw = lw;
 
         let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
             backends: wgpu::Backends::VULKAN,
@@ -128,8 +1162,7 @@ impl State {
                 &wgpu::DeviceDescriptor {
                     label: Some("GPU Device"),
                     required_features: wgpu::Features::empty(),
-                    required_limits: wgpu::Limits::downlevel_webgl2_defaults()
-                        .using_resolution(adapter.limits()),
+                    required_limits: wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()),
                     memory_hints: wgpu::MemoryHints::MemoryUsage,
                 },
                 None,
@@ -138,32 +1171,133 @@ impl State {
             .expect("Failed to create device");
 
         let config = surface
-            .get_default_config(&adapter, size.width.max(1), size.height.max(1))
+            .get_default_config(&adapter, pw, ph)
             .expect("Failed to get surface config");
         surface.configure(&device, &config);
 
-        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
-            label: Some("Shader"),
-            source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
+        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+            label: Some("Shader"),
+            source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
+        });
+
+        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+            label: Some("Pipeline Layout"),
+            bind_group_layouts: &[],
+            push_constant_ranges: &[],
+        });
+
+        let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("Render Pipeline"),
+            layout: Some(&pipeline_layout),
+            vertex: wgpu::VertexState {
+                module: &shader,
+                entry_point: Some("vs_main"),
+                buffers: &[Vertex::desc()],
+                compilation_options: Default::default(),
+            },
+            fragment: Some(wgpu::FragmentState {
+                module: &shader,
+                entry_point: Some("fs_main"),
+                targets: &[Some(wgpu::ColorTargetState {
+                    format: config.format,
+                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
+                    write_mask: wgpu::ColorWrites::ALL,
+                })],
+                compilation_options: Default::default(),
+            }),
+            primitive: wgpu::PrimitiveState {
+                topology: wgpu::PrimitiveTopology::TriangleList,
+                strip_index_format: None,
+                front_face: wgpu::FrontFace::Ccw,
+                cull_mode: None,
+                polygon_mode: wgpu::PolygonMode::Fill,
+                unclipped_depth: false,
+                conservative: false,
+            },
+            depth_stencil: None,
+            multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
+            multiview: None,
+            cache: None,
+        });
+
+        // 3D pipeline
+        let shader_3d = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+            label: Some("Shader 3D"),
+            source: wgpu::ShaderSource::Wgsl(include_str!("shader_3d.wgsl").into()),
+        });
+
+        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("Uniform Buffer"),
+            size: 64,
+            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+            mapped_at_creation: false,
+        });
+
+        let bind_group_layout_3d = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+            label: Some("3D Bind Group Layout"),
+            entries: &[wgpu::BindGroupLayoutEntry {
+                binding: 0,
+                visibility: wgpu::ShaderStages::VERTEX,
+                ty: wgpu::BindingType::Buffer {
+                    ty: wgpu::BufferBindingType::Uniform,
+                    has_dynamic_offset: false,
+                    min_binding_size: wgpu::BufferSize::new(64),
+                },
+                count: None,
+            }],
         });
 
-        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
-            label: Some("Pipeline Layout"),
-            bind_group_layouts: &[],
+        let pipeline_layout_3d = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+            label: Some("3D Pipeline Layout"),
+            bind_group_layouts: &[&bind_group_layout_3d],
             push_constant_ranges: &[],
         });
 
-        let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
-            label: Some("Render Pipeline"),
-            layout: Some(&pipeline_layout),
+        let bind_group_3d = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: Some("3D Bind Group"),
+            layout: &bind_group_layout_3d,
+            entries: &[wgpu::BindGroupEntry {
+                binding: 0,
+                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
+                    buffer: &uniform_buffer,
+                    offset: 0,
+                    size: wgpu::BufferSize::new(64),
+                }),
+            }],
+        });
+
+        let vertex_buffer_3d = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
+            label: Some("Cube Vertex Buffer"),
+            contents: bytemuck::cast_slice(&cube_vertices()),
+            usage: wgpu::BufferUsages::VERTEX,
+        });
+
+        let vertex_buffer_spheres = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("Sphere Node Vertex Buffer"),
+            size: 1,
+            usage: wgpu::BufferUsages::VERTEX,
+            mapped_at_creation: false,
+        });
+
+        let grid_verts = grid_vertices();
+        let vertex_count_grid = grid_verts.len() as u32;
+        let vertex_buffer_grid = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
+            label: Some("Grid Vertex Buffer"),
+            contents: bytemuck::cast_slice(&grid_verts),
+            usage: wgpu::BufferUsages::VERTEX,
+        });
+
+        let pipeline_3d = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("3D Pipeline"),
+            layout: Some(&pipeline_layout_3d),
             vertex: wgpu::VertexState {
-                module: &shader,
+                module: &shader_3d,
                 entry_point: Some("vs_main"),
-                buffers: &[Vertex::desc()],
+                buffers: &[Vertex3D::desc()],
                 compilation_options: Default::default(),
             },
             fragment: Some(wgpu::FragmentState {
-                module: &shader,
+                module: &shader_3d,
                 entry_point: Some("fs_main"),
                 targets: &[Some(wgpu::ColorTargetState {
                     format: config.format,
@@ -176,17 +1310,19 @@ impl State {
                 topology: wgpu::PrimitiveTopology::TriangleList,
                 strip_index_format: None,
                 front_face: wgpu::FrontFace::Ccw,
-                cull_mode: None,
+                cull_mode: Some(wgpu::Face::Back),
                 polygon_mode: wgpu::PolygonMode::Fill,
                 unclipped_depth: false,
                 conservative: false,
             },
-            depth_stencil: None,
-            multisample: wgpu::MultisampleState {
-                count: 1,
-                mask: !0,
-                alpha_to_coverage_enabled: false,
-            },
+            depth_stencil: Some(wgpu::DepthStencilState {
+                format: wgpu::TextureFormat::Depth32Float,
+                depth_write_enabled: true,
+                depth_compare: wgpu::CompareFunction::Less,
+                stencil: wgpu::StencilState::default(),
+                bias: wgpu::DepthBiasState::default(),
+            }),
+            multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
             multiview: None,
             cache: None,
         });
@@ -198,21 +1334,57 @@ impl State {
         let text_renderer = TextRenderer::new(&mut text_atlas, &device, wgpu::MultisampleState::default(), None);
 
         let mut text_viewport = Viewport::new(&device, &cache);
-        text_viewport.update(&queue, Resolution { width: size.width, height: size.height });
+        text_viewport.update(&queue, Resolution { width: pw, height: ph });
 
-        let title_buffer = make_text_buffer(&mut font_system, "Clear Designer", 16.0);
         let status_buffer = make_text_buffer(&mut font_system, "Ready", 12.0);
 
-        let layout = Layout::new(sw, sh);
+        let splitter1_x = (sw - SPLITTER_W) * 0.25;
+        let splitter2_x = (sw - SPLITTER_W) * 0.75;
+        let (depth_texture, depth_texture_view) = {
+            let tex = device.create_texture(&wgpu::TextureDescriptor {
+                label: Some("Depth Texture"),
+                size: wgpu::Extent3d { width: pw.max(1), height: ph.max(1), depth_or_array_layers: 1 },
+                mip_level_count: 1,
+                sample_count: 1,
+                dimension: wgpu::TextureDimension::D2,
+                format: wgpu::TextureFormat::Depth32Float,
+                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+                view_formats: &[],
+            });
+            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
+            (tex, view)
+        };
 
-        let widgets: Vec<Box<dyn Widget>> = vec![
-            Box::new(Header::new()),
-            Box::new(Sidebar::new(60.0)),
+        let fs_root = load_fs_tree();
+        let node_templates = flatten_node_templates(&fs_root);
+        let node_slots: Vec<usize> = (NODE_SLOT_START..NODE_SLOT_START + NODE_SLOT_COUNT).collect();
+        let mut left_offsets: Vec<(f32, f32)> = (0..NODE_SLOT_COUNT)
+            .map(|i| (0.0, i as f32 * 2.0))
+            .collect();
+        if left_offsets.len() > 1 { left_offsets[1] = (0.0, 3.0); }
+
+        let mut widgets: Vec<Box<dyn Widget>> = vec![
+            Box::new(MenuBar::new(0.0, 0.0, 0.0, HEADER_H).with_title("Clear Designer").with_item("File", &["New Project", "Open", "Save", "Configure", "Exit"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out", "Reset Zoom"]).with_item("Help", &["About"])),
             Box::new(ContentBg::new()),
-            Box::new(Panel::new(0.0, 0.0, 320.0, 240.0)),
-            Box::new(Panel::new(0.0, 0.0, 240.0, 180.0)),
-            Box::new(StatusBar::new()),
+            Box::new(Splitter::new(SPLITTER_W)),
+            Box::new(ViewportBg::new()),
+            Box::new(Splitter::new(SPLITTER_W)),
+            Box::new(ParametersBg::new()),
+            Box::new(Canvas::new()),
+            Box::new(MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("Network").with_item("File", &["New", "Open", "Save"]).with_item("Edit", &["Undo", "Redo"]).with_item("View", &["Zoom In", "Zoom Out"])),
+            Box::new(MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("Viewport").with_item("Camera", &["Perspective", "Orthographic"]).with_item("Display", &["Square Aspect"]).with_item("Guides", &["Show Grid", "Cube"])),
+            Box::new(MenuBar::new(0.0, 0.0, 0.0, MENUBAR_H).with_title("Parameters").with_item("Preset", &["Default", "Custom"]).with_item("Reset", &["All"])),
         ];
+        for _ in 0..NODE_SLOT_COUNT {
+            widgets.push(Box::new(Node::new(0.0, 0.0, 0.0, 0.0, "")));
+        }
+        widgets.push(Box::new(StatusBar::new()));
+        widgets.push(Box::new(Breadcrumb::new()));
+        widgets.push(Box::new(ConfigDialog::new()));
+        widgets.push(Box::new(NodePalette::new()));
+
+        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 vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
             label: Some("Vertex Buffer"),
@@ -230,45 +1402,303 @@ impl State {
             render_pipeline,
             vertex_buffer,
             vertex_count: 0,
+            pipeline_3d,
+            bind_group_3d,
+            bind_group_layout_3d,
+            uniform_buffer,
+            vertex_buffer_3d,
+            vertex_count_3d: cube_vertices().len() as u32,
+            vertex_buffer_spheres,
+            vertex_count_spheres: 0,
+            vertex_buffer_grid,
+            vertex_count_grid,
+            depth_texture,
+            depth_texture_view,
+            rotation: 0.0,
+            show_grid: false,
+            show_cube: false,
+            fs_root,
+            node_templates,
+            current_path: Vec::new(),
+            last_click: None,
+            keybinds: default_keybinds(),
+            pending_action: None,
             widgets,
-            layout,
+            positions,
+            node_slots,
+            splitter1_x,
+            splitter2_x,
+            left_offsets,
+            node_palette_visible: false,
+            node_palette_query: String::new(),
+            node_palette_filtered: Vec::new(),
+            node_palette_selected: 0,
             font_system,
             swash_cache,
             text_atlas,
             text_renderer,
             text_viewport,
-            title_buffer,
             status_buffer,
             drag_widget: None,
+            focused_widget: None,
             cursor_x: 0.0,
             cursor_y: 0.0,
-            width: size.width,
-            height: size.height,
+            grid_cursor_col: 0,
+            grid_cursor_row: 0,
+            modifiers: ModifiersState::default(),
+            width: lw,
+            height: lh,
+            physical_width: pw,
+            physical_height: ph,
+            scale,
+            square_viewport: false,
+            grid_snap_enabled: true,
+            network_grid_visible: true,
+            grid_size_x: 75.0,
+            grid_size_y: 75.0,
+
+            pan_x: 0.0,
+            pan_y: 0.0,
+            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,
         };
 
+        state.sync_nodes();
+        state.rebuild_scene_geometry();
+        state.sync_grid_settings();
+        state.widgets[RIGHT_MENUBAR_IDX].set_item_checked(2, 0, state.show_grid);
+        state.widgets[RIGHT_MENUBAR_IDX].set_item_checked(2, 1, state.show_cube);
+
+        state.rebuild_positions();
         state.apply_layout();
+        state.update_panel_bounds();
         state.upload_vertices();
         state
     }
 
+    fn sync_grid_settings(&mut self) {
+        let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
+        self.widgets[CONTENT_IDX].set_show_network_grid(self.network_grid_visible);
+        self.widgets[CONTENT_IDX].set_grid_sizes(self.grid_size_x, self.grid_size_y);
+        self.widgets[CONTENT_IDX].set_grid_origin(self.pan_x, node_area_y + self.pan_y);
+
+        for &i in &self.node_slots {
+            let gx = if self.grid_snap_enabled { self.grid_size_x } else { 0.0 };
+            let gy = if self.grid_snap_enabled { self.grid_size_y } else { 0.0 };
+            self.widgets[i].set_grid_snap(gx, gy);
+            self.widgets[i].set_grid_origin(self.pan_x, node_area_y + self.pan_y);
+        }
+    }
+
+    fn zoom(&mut self, factor: f32) {
+        let old_gx = self.grid_size_x;
+        let old_gy = self.grid_size_y;
+
+        let new_gx = (old_gx * factor).clamp(15.0, 250.0);
+        let new_gy = (old_gy * factor).clamp(15.0, 250.0);
+
+        if (new_gx - old_gx).abs() < 0.01 {
+            return;
+        }
+
+        let col = self.grid_cursor_col as f32;
+        let row = self.grid_cursor_row as f32;
+
+        self.pan_x += (col + 1.0) * (old_gx - new_gx);
+        self.pan_y += (row + 0.5) * (old_gy - new_gy);
+
+        self.grid_size_x = new_gx;
+        self.grid_size_y = new_gy;
+
+        self.sync_grid_settings();
+        for &i in &self.node_slots {
+            let (x, y, _, _) = self.widgets[i].rect();
+            self.widgets[i].set_rect(x, y, self.grid_size_x * 2.0, self.grid_size_y);
+        }
+    }
+
+    fn keep_cursor_in_view(&mut self) {
+        let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
+        let cx = self.grid_cursor_col as f32 * self.grid_size_x + self.pan_x;
+        let cy = node_area_y + self.grid_cursor_row as f32 * self.grid_size_y + self.pan_y;
+        let cw = self.grid_size_x * 2.0;
+        let ch = self.grid_size_y;
+        let clw = self.content_left_w();
+        let max_y = self.height - STATUS_H;
+
+        if cx < 0.0 {
+            self.pan_x -= cx;
+        } else if cx + cw > clw {
+            self.pan_x -= cx + cw - clw;
+        }
+
+        if cy < node_area_y {
+            self.pan_y -= cy - node_area_y;
+        } else if cy + ch > max_y {
+            self.pan_y -= cy + ch - max_y;
+        }
+        self.sync_grid_settings();
+    }
+
+    fn rebuild_positions(&mut self) {
+        self.clamp_splitters();
+
+        let body_h = self.body_h();
+        let clw = self.content_left_w();
+        let crx = self.content_right_x();
+        let vw = self.viewport_w();
+        let px = self.param_x();
+
+        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[CANVAS_IDX] = (0.0, HEADER_H, self.width, body_h);
+
+        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);
+
+        let node_area_y = HEADER_H + MENUBAR_H;
+        self.positions[BREADCRUMB_IDX] = (0.0, node_area_y, clw, BREADCRUMB_H);
+
+        let active_nodes = self.current_dir().children.len().min(self.node_slots.len());
+        for (slot_idx, (&idx, off)) in self.node_slots.iter().zip(self.left_offsets.iter()).enumerate() {
+            if slot_idx < active_nodes {
+                self.positions[idx] = (off.0 * self.grid_size_x + self.pan_x, node_area_y + BREADCRUMB_H + off.1 * self.grid_size_y + self.pan_y, self.grid_size_x * 2.0, self.grid_size_y);
+            } else {
+                self.positions[idx] = (0.0, 0.0, 0.0, 0.0);
+            }
+        }
+
+        self.positions[STATUS_IDX] = (0.0, self.height - STATUS_H, self.width, STATUS_H);
+        self.positions[CONFIG_DIALOG_IDX] = (0.0, 0.0, self.width, self.height);
+        self.positions[NODE_PALETTE_IDX] = (0.0, 0.0, self.width, self.height);
+    }
+
     fn apply_layout(&mut self) {
-        for (i, node_id) in self.layout.widget_nodes.iter().enumerate() {
+        for (i, pos) in self.positions.iter().enumerate() {
             if let Some(widget) = self.widgets.get_mut(i) {
-                if widget.is_dragging() {
-                    continue;
-                }
-                let (x, y, w, h) = self.layout.widget_rect(*node_id);
+                if widget.is_dragging() { continue; }
+                let (x, y, w, h) = *pos;
                 widget.set_rect(x, y, w, h);
             }
         }
     }
 
+    fn update_panel_bounds(&mut self) {
+        // Unbounded 2D canvas - no clamping
+    }
+
+    fn sync_layout(&mut self) {
+        self.splitter1_x = self.widgets[SPLITTER1_IDX].rect().0;
+        self.splitter2_x = self.widgets[SPLITTER2_IDX].rect().0;
+        self.rebuild_positions();
+        self.apply_layout();
+        self.update_panel_bounds();
+    }
+
+    fn execute_action(&mut self, action: Action) {
+        match action {
+            Action::ToggleGrid => self.show_grid = !self.show_grid,
+            Action::ToggleCube => self.show_cube = !self.show_cube,
+            Action::ToggleSquareViewport => self.square_viewport = !self.square_viewport,
+            Action::ToggleConfigure => {
+                if self.widgets[CONFIG_DIALOG_IDX].take_click() {
+                    self.widgets[CONFIG_DIALOG_IDX].set_visible(false);
+                } else {
+                    self.widgets[CONFIG_DIALOG_IDX].set_config_toggle(0, self.grid_snap_enabled);
+                    self.widgets[CONFIG_DIALOG_IDX].set_config_toggle(1, self.network_grid_visible);
+                    self.widgets[CONFIG_DIALOG_IDX].set_config_spin(0, self.grid_size_x);
+                    self.widgets[CONFIG_DIALOG_IDX].set_config_spin(1, self.grid_size_y);
+                    self.widgets[CONFIG_DIALOG_IDX].set_visible(true);
+                }
+                self.upload_vertices();
+                return;
+            }
+        }
+
+    }
+
+    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());
+        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).round();
+            let r = (((py - node_area_y) - self.pan_y) / self.grid_size_y).round();
+            *off = (c, r);
+        }
+    }
+
     fn collect_vertices(&self) -> Vec<Vertex> {
-        let sw = self.width as f32;
-        let sh = self.height as f32;
+        let sw = self.width;
+        let sh = self.height;
         let mut verts = Vec::new();
-        for w in &self.widgets {
-            verts.extend(widget_vertices(w.as_ref(), sw, sh));
+
+        let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
+        let dialog_open = self.widgets[CONFIG_DIALOG_IDX].visible() || self.node_palette_visible;
+        let show_cursor = self.drag_widget.is_none()
+            && !dialog_open
+            && self.cursor_x >= 0.0
+            && self.cursor_x < self.content_left_w()
+            && self.cursor_y >= node_area_y
+            && self.cursor_y < self.height - STATUS_H;
+
+        let clip = (0.0, node_area_y, self.content_left_w(), self.height - STATUS_H);
+
+        for (i, w) in self.widgets.iter().enumerate() {
+            if i == CONTENT_IDX {
+                verts.extend(widget_vertices(w.as_ref(), sw, sh));
+                for (qx, qy, qw, qh, qc) in w.extra_quads() {
+                    verts.extend(quad_vertices_clipped(qx, qy, qw, qh, sw, sh, qc, clip));
+                }
+            } else if self.node_slots.contains(&i) {
+                verts.extend(widget_vertices_clipped(w.as_ref(), sw, sh, clip));
+                for (qx, qy, qw, qh, qc) in w.extra_quads() {
+                    verts.extend(quad_vertices_clipped(qx, qy, qw, qh, sw, sh, qc, clip));
+                }
+            } else {
+                verts.extend(widget_vertices(w.as_ref(), sw, sh));
+                for (qx, qy, qw, qh, qc) in w.extra_quads() {
+                    verts.extend(quad_vertices(qx, qy, qw, qh, sw, sh, qc));
+                }
+            }
+
+            if i == CONTENT_IDX && show_cursor {
+                let cx = self.grid_cursor_col as f32 * self.grid_size_x + self.pan_x;
+                let cy = node_area_y + self.grid_cursor_row as f32 * self.grid_size_y + self.pan_y;
+                let cw = self.grid_size_x * 2.0;
+                let ch = self.grid_size_y;
+
+                let bg_color = [0.15, 0.25, 0.45, 0.15];
+                let border_color = [0.35, 0.65, 0.95, 0.60];
+                let thickness = 2.0_f32;
+
+                // Filled cursor background
+                verts.extend(quad_vertices_clipped(
+                    cx + thickness,
+                    cy + thickness,
+                    cw - 2.0 * thickness,
+                    ch - 2.0 * thickness,
+                    sw,
+                    sh,
+                    bg_color,
+                    clip,
+                ));
+
+                // 4 border edges
+                verts.extend(quad_vertices_clipped(cx, cy, cw, thickness, sw, sh, border_color, clip));
+                verts.extend(quad_vertices_clipped(cx, cy + ch - thickness, cw, thickness, sw, sh, border_color, clip));
+                verts.extend(quad_vertices_clipped(cx, cy + thickness, thickness, ch - 2.0 * thickness, sw, sh, border_color, clip));
+                verts.extend(quad_vertices_clipped(cx + cw - thickness, cy + thickness, thickness, ch - 2.0 * thickness, sw, sh, border_color, clip));
+            }
         }
         verts
     }
@@ -289,74 +1719,112 @@ impl State {
         self.queue.write_buffer(&self.vertex_buffer, 0, data);
     }
 
+    fn rebuild_scene_geometry(&mut self) {
+        let verts = network_sphere_vertices(&self.fs_root);
+        self.vertex_count_spheres = verts.len() as u32;
+        if verts.is_empty() {
+            self.vertex_buffer_spheres = self.device.create_buffer(&wgpu::BufferDescriptor {
+                label: Some("Sphere Node Vertex Buffer"),
+                size: 1,
+                usage: wgpu::BufferUsages::VERTEX,
+                mapped_at_creation: false,
+            });
+        } else {
+            self.vertex_buffer_spheres = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
+                label: Some("Sphere Node Vertex Buffer"),
+                contents: bytemuck::cast_slice(&verts),
+                usage: wgpu::BufferUsages::VERTEX,
+            });
+        }
+    }
+
     fn update_status_text(&mut self, text: &str) {
         self.status_buffer = make_text_buffer(&mut self.font_system, text, 12.0);
     }
 
     fn prepare_text(&mut self) {
+        let splitter1_x = self.splitter1_x;
+        let height = self.height;
         let Self {
-            ref mut text_renderer,
-            ref device,
-            ref queue,
-            ref mut font_system,
-            ref mut text_atlas,
-            ref mut text_viewport,
-            ref mut swash_cache,
-            ref title_buffer,
-            ref status_buffer,
-            width,
-            height,
-            ..
+            ref mut text_renderer, ref device, ref queue,
+            ref mut font_system, ref mut text_atlas, ref mut text_viewport,
+            ref mut swash_cache, ref status_buffer,
+            physical_width, physical_height, scale,
+            ref widgets, ref node_slots, ..
         } = self;
 
-        let viewport = Resolution { width: *width, height: *height };
+        let viewport = Resolution { width: *physical_width, height: *physical_height };
         text_viewport.update(queue, viewport);
+        let s = *scale as f32;
 
-        let areas = [
+        let mut areas: Vec<TextArea> = vec![
             TextArea {
-                buffer: title_buffer,
-                left: 80.0,
-                top: 12.0,
-                scale: 1.0,
-                bounds: TextBounds {
-                    left: 0,
-                    top: 0,
-                    right: *width as i32,
-                    bottom: *height as i32,
-                },
-                default_color: glyphon::Color::rgb(0xcc, 0xcc, 0xd4),
+                buffer: status_buffer,
+                left: 12.0 * s, top: *physical_height as f32 - 24.0 * s, scale: s,
+                bounds: TextBounds { left: 0, top: 0, right: *physical_width as i32, bottom: *physical_height as i32 },
+                default_color: glyphon::Color::rgb(0xaa, 0xaa, 0xbb),
                 custom_glyphs: &[],
             },
-            TextArea {
-                buffer: status_buffer,
-                left: 12.0,
-                top: *height as f32 - 24.0,
-                scale: 1.0,
-                bounds: TextBounds {
+        ];
+
+        let mut widget_buffers: Vec<Buffer> = Vec::new();
+        let mut widget_labels: Vec<TextLabel> = Vec::new();
+        let mut widget_is_node: Vec<bool> = Vec::new();
+        for (i, w) in widgets.iter().enumerate() {
+            let is_node = node_slots.contains(&i);
+            for label in w.text_labels() {
+                widget_buffers.push(make_text_buffer(font_system, &label.text, label.font_size));
+                widget_labels.push(label);
+                widget_is_node.push(is_node);
+            }
+        }
+
+        for ((buf, label), &is_node) in widget_buffers.iter().zip(widget_labels.iter()).zip(widget_is_node.iter()) {
+            let bounds = if is_node {
+                let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
+                TextBounds {
+                    left: 0,
+                    top: (node_area_y * s) as i32,
+                    right: (splitter1_x * s) as i32,
+                    bottom: (((height - STATUS_H) * s) as i32).max(0),
+                }
+            } else {
+                TextBounds {
                     left: 0,
                     top: 0,
-                    right: *width as i32,
-                    bottom: *height as i32,
-                },
-                default_color: glyphon::Color::rgb(0x55, 0x55, 0x66),
+                    right: *physical_width as i32,
+                    bottom: *physical_height as i32,
+                }
+            };
+            areas.push(TextArea {
+                buffer: buf,
+                left: label.x * s, top: label.y * s, scale: s,
+                bounds,
+                default_color: glyphon::Color::rgb(label.color[0], label.color[1], label.color[2]),
                 custom_glyphs: &[],
-            },
-        ];
+            });
+        }
 
-        text_renderer
-            .prepare(device, queue, font_system, text_atlas, text_viewport, areas, swash_cache)
-            .unwrap();
+        text_renderer.prepare(device, queue, font_system, text_atlas, text_viewport, areas, swash_cache).unwrap();
     }
 
     fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
         if new_size.width > 0 && new_size.height > 0 {
-            self.width = new_size.width;
-            self.height = new_size.height;
+            self.physical_width = new_size.width;
+            self.physical_height = new_size.height;
+            self.width = new_size.width as f32 / self.scale as f32;
+            self.height = new_size.height as f32 / self.scale as f32;
             self.config.width = new_size.width;
             self.config.height = new_size.height;
             self.surface.configure(&self.device, &self.config);
-            self.layout.resize(new_size.width as f32, new_size.height as f32);
-            self.apply_layout();
+
+            let (tex, view) = self.create_depth_texture();
+            self.depth_texture = tex;
+            self.depth_texture_view = view;
+
+            self.sync_layout();
+            self.read_panel_offsets();
+            self.keep_cursor_in_view();
             self.upload_vertices();
         }
     }
@@ -364,19 +1832,51 @@ impl State {
     fn handle_event(&mut self, event: &WindowEvent) -> bool {
         match event {
             WindowEvent::CursorMoved { position, .. } => {
-                self.cursor_x = position.x as f32;
-                self.cursor_y = position.y as f32;
+                let old_cx = self.cursor_x;
+                let old_cy = self.cursor_y;
+                self.cursor_x = position.x as f32 / self.scale as f32;
+                self.cursor_y = position.y as f32 / self.scale as f32;
                 let mut changed = false;
 
-                if let Some(idx) = self.drag_widget {
-                    if self.widgets[idx].drag_update(self.cursor_x, self.cursor_y) {
-                        changed = true;
+                if self.is_panning {
+                    let dx = self.cursor_x - self.pan_start_cx;
+                    let dy = self.cursor_y - self.pan_start_cy;
+                    self.pan_x = self.pan_start_x + dx;
+                    self.pan_y = self.pan_start_y + dy;
+                    self.sync_grid_settings();
+                    self.rebuild_positions();
+                    self.apply_layout();
+                    self.update_panel_bounds();
+                    changed = true;
+                } else {
+                    if let Some(idx) = self.drag_widget {
+                        if self.widgets[idx].drag_update(self.cursor_x, self.cursor_y) {
+                            changed = true;
+                        }
                     }
-                }
 
-                if self.drag_widget.is_none() {
-                    for w in &mut self.widgets {
-                        if w.cursor_moved(self.cursor_x, self.cursor_y) {
+                    if self.drag_widget.is_none() {
+                        for w in &mut self.widgets {
+                            if w.cursor_moved(self.cursor_x, self.cursor_y) {
+                                changed = true;
+                            }
+                        }
+
+                        let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
+                        let dialog_open = self.widgets[CONFIG_DIALOG_IDX].visible() || self.node_palette_visible;
+                        let show_old = old_cx >= 0.0
+                            && old_cx < self.content_left_w()
+                            && old_cy >= node_area_y
+                            && old_cy < self.height - STATUS_H
+                            && !dialog_open;
+
+                        let show_new = self.cursor_x >= 0.0
+                            && self.cursor_x < self.content_left_w()
+                            && self.cursor_y >= node_area_y
+                            && self.cursor_y < self.height - STATUS_H
+                            && !dialog_open;
+
+                        if show_old != show_new {
                             changed = true;
                         }
                     }
@@ -384,26 +1884,105 @@ impl State {
                 changed
             }
             WindowEvent::MouseInput { state: btn_state, button, .. } => {
+                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
+                    && self.cursor_x < self.content_left_w()
+                    && self.cursor_y >= node_area_y
+                    && self.cursor_y < self.height - STATUS_H;
+
+                let is_pan_trigger = !dialog_open
+                    && in_network_pane
+                    && (*button == MouseButton::Middle
+                        || *button == MouseButton::Right
+                        || (*button == MouseButton::Left && self.space_pressed));
+
+                if is_pan_trigger {
+                    match btn_state {
+                        ElementState::Pressed => {
+                            self.is_panning = true;
+                            self.pan_start_cx = self.cursor_x;
+                            self.pan_start_cy = self.cursor_y;
+                            self.pan_start_x = self.pan_x;
+                            self.pan_start_y = self.pan_y;
+                            return true;
+                        }
+                        ElementState::Released => {
+                            if self.is_panning {
+                                self.is_panning = false;
+                                self.sync_layout();
+                                self.read_panel_offsets();
+                                self.upload_vertices();
+                                return true;
+                            }
+                        }
+                    }
+                }
+
+                if self.is_panning && *btn_state == ElementState::Released {
+                    self.is_panning = false;
+                    self.sync_layout();
+                    self.read_panel_offsets();
+                    self.upload_vertices();
+                    return true;
+                }
+
                 if *button != MouseButton::Left { return false; }
                 let mut changed = false;
+                let old_focus = self.focused_widget;
 
                 match btn_state {
                     ElementState::Pressed => {
-                        for i in (0..self.widgets.len()).rev() {
-                            if self.widgets[i].hit_test(self.cursor_x, self.cursor_y) {
-                                if self.widgets[i].mouse_input(*button, *btn_state, self.cursor_x, self.cursor_y) {
-                                    changed = true;
-                                }
-                                if self.widgets[i].draggable() {
-                                    self.widgets[i].drag_begin(self.cursor_x, self.cursor_y);
-                                    self.drag_widget = Some(i);
+                        let click_target = (0..self.widgets.len()).rev()
+                            .find(|&i| self.widgets[i].hit_test(self.cursor_x, self.cursor_y));
+                        if let Some(old) = self.focused_widget {
+                            if click_target != Some(old) {
+                                self.widgets[old].unfocus();
+                                self.focused_widget = None;
+                            }
+                        }
+                        if let Some(i) = click_target {
+                            if self.widgets[i].mouse_input(*button, *btn_state, self.cursor_x, self.cursor_y) {
+                                changed = true;
+                            }
+                            if self.widgets[i].draggable() {
+                                self.widgets[i].drag_begin(self.cursor_x, self.cursor_y);
+                                self.drag_widget = Some(i);
+                            }
+                            self.widgets[i].focus();
+                            self.focused_widget = Some(i);
+                            // Double-click detection for node directory entry
+                            if self.node_slots.contains(&i) {
+                                if let Some((t, prev)) = self.last_click {
+                                    if prev == i && t.elapsed() < std::time::Duration::from_millis(500) {
+                                        let dir_idx = self.node_slots.iter().position(|&x| x == i).unwrap();
+                                        let dir = self.current_dir();
+                                        if dir_idx < dir.children.len() && !dir.children[dir_idx].children.is_empty() {
+                                            self.current_path.push(dir_idx);
+                                            self.pan_x = 0.0;
+                                            self.pan_y = 0.0;
+                                            self.grid_cursor_col = 0;
+                                            self.grid_cursor_row = 0;
+                                            self.sync_grid_settings();
+                                            self.sync_nodes();
+                                            self.rebuild_scene_geometry();
+                                            self.rebuild_positions();
+                                            self.apply_layout();
+                                            self.update_panel_bounds();
+                                            self.upload_vertices();
+                                            changed = true;
+                                        }
+                                    }
                                 }
-                                break;
+                                self.last_click = Some((Instant::now(), i));
+                            } else {
+                                self.last_click = None;
                             }
                         }
                     }
                     ElementState::Released => {
-                        if let Some(idx) = self.drag_widget {
+                        if self.drag_widget.is_some() {
+                            let idx = self.drag_widget.unwrap();
                             self.widgets[idx].drag_end();
                             self.drag_widget = None;
                             changed = true;
@@ -415,13 +1994,163 @@ impl State {
                         }
                     }
                 }
+
+                let active_nodes = self.current_dir().children.len().min(self.node_slots.len());
+                let mut visibility_updates = Vec::new();
+                for (i, &slot) in self.node_slots.iter().enumerate().take(active_nodes) {
+                    if self.widgets[slot].take_geom_toggle() {
+                        let visible = self.widgets[slot].geom_visible();
+                        visibility_updates.push((i, visible));
+                    }
+                }
+                for (i, visible) in visibility_updates {
+                    self.current_dir_mut().children[i].geometry_visible = visible;
+                    self.rebuild_scene_geometry();
+                    changed = true;
+                }
+
+                if self.focused_widget != old_focus {
+                    changed = true;
+                }
                 changed
             }
+            WindowEvent::ModifiersChanged(mods) => {
+                self.modifiers = mods.state();
+                false
+            }
+            WindowEvent::KeyboardInput { event, .. } => {
+                if event.logical_key == Key::Named(NamedKey::Space) {
+                    self.space_pressed = event.state == ElementState::Pressed;
+                }
+
+                if self.node_palette_visible {
+                    return self.handle_node_palette_key(event);
+                }
+                let mut changed = false;
+                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();
+
+                        match &event.logical_key {
+                            Key::Named(NamedKey::ArrowUp) => {
+                                self.grid_cursor_row -= 1;
+                                changed = true;
+                            }
+                            Key::Named(NamedKey::ArrowDown) => {
+                                self.grid_cursor_row += 1;
+                                changed = true;
+                            }
+                            Key::Named(NamedKey::ArrowLeft) => {
+                                self.grid_cursor_col -= 1;
+                                changed = true;
+                            }
+                            Key::Named(NamedKey::ArrowRight) => {
+                                self.grid_cursor_col += 1;
+                                changed = true;
+                            }
+                            Key::Character(s) if is_plain_key => {
+                                match s.as_str() {
+                                    "k" | "K" => {
+                                        self.grid_cursor_row -= 1;
+                                        changed = true;
+                                    }
+                                    "j" | "J" => {
+                                        self.grid_cursor_row += 1;
+                                        changed = true;
+                                    }
+                                    "h" | "H" => {
+                                        self.grid_cursor_col -= 1;
+                                        changed = true;
+                                    }
+                                    "l" | "L" => {
+                                        self.grid_cursor_col += 1;
+                                        changed = true;
+                                    }
+                                    "-" => {
+                                        self.zoom(1.0 / 1.15);
+                                        changed = true;
+                                    }
+                                    "=" | "+" => {
+                                        self.zoom(1.15);
+                                        changed = true;
+                                    }
+                                    _ => {}
+                                }
+                            }
+                            _ => {}
+                        }
+                    }
+
+                    if changed {
+                        self.keep_cursor_in_view();
+                    }
+
+                    if !changed {
+                        if event.logical_key == Key::Named(NamedKey::Tab) && !self.widgets[CONFIG_DIALOG_IDX].visible() {
+                            self.open_node_palette();
+                            return true;
+                        }
+                        for bind in &self.keybinds {
+                            if bind.matches(&self.modifiers, &event.logical_key) {
+                                self.pending_action = Some(bind.action);
+                                return true;
+                            }
+                        }
+                    }
+                }
+                if changed {
+                    true
+                } else if let Some(idx) = self.focused_widget {
+                    self.widgets[idx].keyboard_input(event)
+                } else { false }
+            }
             _ => false,
         }
     }
 
     fn render(&mut self) {
+        let mut panned = false;
+        if let Some(idx) = self.drag_widget {
+            if self.node_slots.contains(&idx) {
+                let node_area_y = HEADER_H + MENUBAR_H + BREADCRUMB_H;
+                let margin = 30.0_f32;
+                let pan_speed = 5.0_f32;
+                let clw = self.content_left_w();
+                let max_y = self.height - STATUS_H;
+
+                if self.cursor_x >= -50.0 && self.cursor_x < clw + 50.0
+                    && self.cursor_y >= node_area_y - 50.0 && self.cursor_y < max_y + 50.0
+                {
+                    if self.cursor_x < margin {
+                        self.pan_x += pan_speed;
+                        panned = true;
+                    } else if self.cursor_x > clw - margin {
+                        self.pan_x -= pan_speed;
+                        panned = true;
+                    }
+
+                    if self.cursor_y < node_area_y + margin {
+                        self.pan_y += pan_speed;
+                        panned = true;
+                    } else if self.cursor_y > max_y - margin {
+                        self.pan_y -= pan_speed;
+                        panned = true;
+                    }
+                }
+            }
+        }
+
+        if panned {
+            self.sync_grid_settings();
+            if let Some(idx) = self.drag_widget {
+                self.widgets[idx].drag_update(self.cursor_x, self.cursor_y);
+            }
+            self.sync_layout();
+            self.read_panel_offsets();
+            self.upload_vertices();
+            self.window.request_redraw();
+        }
+
         self.prepare_text();
 
         let output = match self.surface.get_current_texture() {
@@ -431,28 +2160,92 @@ impl State {
                 return;
             }
             Err(wgpu::SurfaceError::Timeout) => return,
-            Err(e) => {
-                eprintln!("Surface error: {e:?}");
-                return;
-            }
+            Err(e) => { eprintln!("Surface error: {e:?}"); return; }
         };
 
         let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
-
         let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
             label: Some("Encoder"),
         });
 
+        // 3D canvas render pass (background layer)
+        {
+            let (cx, cy, cw_logical, ch_logical) = self.widgets[CANVAS_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;
+            let mut sy = (cy * self.scale as f32) as u32;
+
+            if self.square_viewport {
+                let s = cw.min(ch);
+                sx += (cw - s) / 2;
+                sy += (ch - s) / 2;
+                cw = s;
+                ch = s;
+            }
+
+            if cw > 0 && ch > 0 {
+                let aspect = cw as f32 / ch as f32;
+
+                self.rotation += 0.015;
+                let proj = Mat4::perspective_rh(0.9, aspect, 0.1, 100.0);
+                let view_mat = Mat4::look_at_rh(Vec3::new(2.5, 1.8, 2.5), Vec3::ZERO, Vec3::Y);
+                let model = Mat4::from_rotation_y(self.rotation) * Mat4::from_rotation_x(self.rotation * 0.2);
+                let mvp = proj * view_mat * model;
+                self.queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[mvp.to_cols_array_2d()]));
+
+                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                    label: Some("3D Render Pass"),
+                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                        view: &view,
+                        resolve_target: None,
+                        ops: wgpu::Operations {
+                            load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.05, g: 0.05, b: 0.10, a: 1.0 }),
+                            store: wgpu::StoreOp::Store,
+                        },
+                    })],
+                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+                        view: &self.depth_texture_view,
+                        depth_ops: Some(wgpu::Operations {
+                            load: wgpu::LoadOp::Clear(1.0),
+                            store: wgpu::StoreOp::Discard,
+                        }),
+                        stencil_ops: None,
+                    }),
+                    timestamp_writes: None,
+                    occlusion_query_set: None,
+                });
+
+                pass.set_scissor_rect(sx, sy, cw, ch);
+                pass.set_pipeline(&self.pipeline_3d);
+                pass.set_bind_group(0, &self.bind_group_3d, &[]);
+
+                if self.show_grid {
+                    pass.set_vertex_buffer(0, self.vertex_buffer_grid.slice(..));
+                    pass.draw(0..self.vertex_count_grid, 0..1);
+                }
+
+                if self.show_cube {
+                    pass.set_vertex_buffer(0, self.vertex_buffer_3d.slice(..));
+                    pass.draw(0..self.vertex_count_3d, 0..1);
+                }
+
+                if self.vertex_count_spheres > 0 {
+                    pass.set_vertex_buffer(0, self.vertex_buffer_spheres.slice(..));
+                    pass.draw(0..self.vertex_count_spheres, 0..1);
+                }
+            }
+        }
+
+        // UI render pass (foreground layer)
         {
             let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                label: Some("Render Pass"),
+                label: Some("UI Render Pass"),
                 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                     view: &view,
                     resolve_target: None,
                     ops: wgpu::Operations {
-                        load: wgpu::LoadOp::Clear(wgpu::Color {
-                            r: 0.06, g: 0.06, b: 0.08, a: 1.0,
-                        }),
+                        load: wgpu::LoadOp::Load,
                         store: wgpu::StoreOp::Store,
                     },
                 })],
@@ -474,26 +2267,20 @@ impl State {
     }
 }
 
-struct App {
-    state: Option<State>,
-}
+struct App { state: Option<State> }
 
-impl App {
-    fn new() -> Self { Self { state: None } }
-}
+impl App { fn new() -> Self { Self { state: None } } }
 
 impl ApplicationHandler for App {
     fn resumed(&mut self, event_loop: &ActiveEventLoop) {
         if self.state.is_some() { return; }
 
         let window = Arc::new(
-            event_loop
-                .create_window(
-                    WindowAttributes::default()
-                        .with_title("Clear Designer")
-                        .with_inner_size(winit::dpi::LogicalSize::new(1280, 800)),
-                )
-                .unwrap(),
+            event_loop.create_window(
+                WindowAttributes::default()
+                    .with_title("Clear Designer")
+                    .with_inner_size(winit::dpi::LogicalSize::new(1280, 800)),
+            ).unwrap(),
         );
 
         let state = pollster::block_on(State::new(window));
@@ -502,49 +2289,140 @@ impl ApplicationHandler for App {
     }
 
     fn window_event(
-        &mut self,
-        event_loop: &ActiveEventLoop,
-        _window_id: winit::window::WindowId,
-        event: WindowEvent,
+        &mut self, event_loop: &ActiveEventLoop,
+        _window_id: winit::window::WindowId, event: WindowEvent,
     ) {
         let needs_redraw = match event {
-            WindowEvent::CloseRequested => {
-                event_loop.exit();
+            WindowEvent::CloseRequested => { event_loop.exit(); true }
+            WindowEvent::Resized(size) => {
+                if let Some(state) = &mut self.state { state.resize(size); }
                 true
             }
-            WindowEvent::Resized(size) => {
+            WindowEvent::RedrawRequested => {
                 if let Some(state) = &mut self.state {
-                    state.resize(size);
+                    state.render();
+                    state.window.request_redraw();
                 }
                 true
             }
-            WindowEvent::RedrawRequested => {
+            WindowEvent::ScaleFactorChanged { scale_factor, mut inner_size_writer } => {
                 if let Some(state) = &mut self.state {
-                    state.render();
+                    let new_physical = winit::dpi::PhysicalSize::new(
+                        (state.width as f64 * scale_factor) as u32,
+                        (state.height as f64 * scale_factor) as u32,
+                    );
+                    let _ = inner_size_writer.request_inner_size(new_physical);
+                    state.scale = scale_factor;
+                    state.resize(new_physical);
                     state.window.request_redraw();
                 }
                 true
             }
             _ => {
                 if let Some(state) = &mut self.state {
-                    let changed = state.handle_event(&event);
+                    let mut changed = state.handle_event(&event);
                     if changed {
+                        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.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();
+                        }
+                    }
+
+                    if let Some(action) = state.pending_action.take() {
+                        state.execute_action(action);
+                    }
+
+                    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_visible(true);
+                            state.upload_vertices();
+                            changed = true;
+                        }
+                    }
+
+                    if let Some((menu_idx, item_idx)) = state.widgets[RIGHT_MENUBAR_IDX].menu_click() {
+                        let action = if menu_idx == 1 {
+                            Some(Action::ToggleSquareViewport)
+                        } else if menu_idx == 2 {
+                            match item_idx {
+                                0 => Some(Action::ToggleGrid),
+                                1 => Some(Action::ToggleCube),
+                                _ => None,
+                            }
+                        } else { None };
+                        if let Some(a) = action { state.execute_action(a); }
+                    }
+
+                    while let Some((id, val)) = state.widgets[CONFIG_DIALOG_IDX].take_config_toggle() {
+                        match id {
+                            0 => {
+                                state.grid_snap_enabled = val;
+                                for &i in &state.node_slots {
+                                    let gx = if val { state.grid_size_x } else { 0.0 };
+                                    let gy = if val { state.grid_size_y } else { 0.0 };
+                                    state.widgets[i].set_grid_snap(gx, gy);
+                                }
+                            }
+                            1 => {
+                                state.network_grid_visible = val;
+                                state.widgets[CONTENT_IDX].set_show_network_grid(val);
+                            }
+                            _ => {}
+                        }
+                        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,
+                            1 => state.grid_size_y = val,
+                            _ => {}
+                        }
+                        state.widgets[CONTENT_IDX].set_grid_sizes(state.grid_size_x, state.grid_size_y);
+                        for &i in &state.node_slots {
+                            let (x, y, _, _) = state.widgets[i].rect();
+                            state.widgets[i].set_rect(x, y, state.grid_size_x * 2.0, state.grid_size_y);
+                        }
+                        state.sync_layout();
+                        state.read_panel_offsets();
+                        state.upload_vertices();
+                        changed = true;
+                    }
+
                     state.update_status_text(&format!(
-                        "x: {:.0}  y: {:.0}",
-                        state.cursor_x, state.cursor_y
+                        "col: {:.0}  vp: {:.0}  params: {:.0}",
+                        state.content_left_w(), state.viewport_w(), state.param_w(),
                     ));
                     changed
-                } else {
-                    false
-                }
+                } else { false }
             }
         };
         if needs_redraw {
-            if let Some(state) = &mut self.state {
-                state.window.request_redraw();
-            }
+            if let Some(state) = &mut self.state { state.window.request_redraw(); }
         }
     }
 }
@@ -552,7 +2430,6 @@ impl ApplicationHandler for App {
 fn main() {
     let event_loop = EventLoop::new().unwrap();
     event_loop.set_control_flow(ControlFlow::Poll);
-
     let mut app = App::new();
     event_loop.run_app(&mut app).unwrap();
 }
diff --git a/src/shader_3d.wgsl b/src/shader_3d.wgsl
new file mode 100644
index 0000000..bdf8e05
--- /dev/null
+++ b/src/shader_3d.wgsl
@@ -0,0 +1,26 @@
+struct Uniforms {
+    mvp: mat4x4<f32>,
+}
+
+@group(0) @binding(0) var<uniform> uniforms: Uniforms;
+
+struct VertexOutput {
+    @builtin(position) position: vec4f,
+    @location(0) color: vec3f,
+};
+
+@vertex
+fn vs_main(
+    @location(0) position: vec3f,
+    @location(1) color: vec3f,
+) -> VertexOutput {
+    var out: VertexOutput;
+    out.position = uniforms.mvp * vec4f(position, 1.0);
+    out.color = color;
+    return out;
+}
+
+@fragment
+fn fs_main(in: VertexOutput) -> @location(0) vec4f {
+    return vec4f(in.color, 1.0);
+}