git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit35bcbc82b577af14019dc74499322176df5ead2a
parent11575abbc7
authorLucas Galante <[email protected]>
date2026-07-06 10:37
Refactor 3D Viewport into standalone Viewport3D widget

 Cargo.toml                                |   1 +
 src/backend/window_runner.rs              |  41 +---
 src/config.rs                             |  10 +-
 src/layout.rs                             |  33 +++-
 src/widget/container/mod.rs               |   2 -
 src/widget/container/plate.rs             |   2 +-
 src/widget/container/section_container.rs |  38 ++++
 src/widget/container/viewport_bg.rs       |  26 ---
 src/widget/display/mod.rs                 |   3 +
 src/widget/display/viewport_3d.rs         | 306 ++++++++++++++++++++++++++++++
 src/widget/input/dropdown.rs              |   2 +-
 src/widget/input/slider.rs                |   4 +-
 src/widget/input/spinbox.rs               |   4 +-
 src/widget/input/trackpad.rs              |   2 +-
 src/widget/mod.rs                         |   5 +-
 15 files changed, 401 insertions(+), 78 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index fa88b2f..854f1c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ path = "src/lib.rs"
 [dependencies]
 raw-window-handle = "0.6"
 wgpu = "24"
+glam = "0.29"
 bytemuck = { version = "1", features = ["derive"] }
 pollster = "0.4"
 glyphon = "0.8"
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 999912c..e665591 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -141,12 +141,6 @@ pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<
         _ => None,
     });
 
-    let resolved_mono = if !mono_fallback.is_empty() {
-        find_cased_family(fs, &mono_fallback)
-    } else {
-        None
-    };
-
     let resolved_sans = if !sans_fallback.is_empty() {
         find_cased_family(fs, &sans_fallback)
     } else {
@@ -160,7 +154,7 @@ pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<
                     if let Some(ref cased) = resolved_storage {
                         glyphon::Family::Name(cased)
                     } else {
-                        glyphon::Family::Name(&mono_fallback)
+                        glyphon::Family::Name(crate::layout::get_system_monospace_font())
                     }
                 } else {
                     glyphon::Family::Name(crate::layout::get_system_monospace_font())
@@ -171,15 +165,7 @@ pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<
                     if let Some(ref cased) = resolved_storage {
                         glyphon::Family::Name(cased)
                     } else {
-                        if !mono_fallback.is_empty() {
-                            if let Some(ref cased_mono) = resolved_mono {
-                                glyphon::Family::Name(cased_mono)
-                            } else {
-                                glyphon::Family::Name(&mono_fallback)
-                            }
-                        } else {
-                            glyphon::Family::Name(crate::layout::get_system_monospace_font())
-                        }
+                        glyphon::Family::SansSerif
                     }
                 } else {
                     glyphon::Family::SansSerif
@@ -190,15 +176,7 @@ pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<
                     if let Some(ref cased) = resolved_storage {
                         glyphon::Family::Name(cased)
                     } else {
-                        if !mono_fallback.is_empty() {
-                            if let Some(ref cased_mono) = resolved_mono {
-                                glyphon::Family::Name(cased_mono)
-                            } else {
-                                glyphon::Family::Name(&mono_fallback)
-                            }
-                        } else {
-                            glyphon::Family::Name(crate::layout::get_system_monospace_font())
-                        }
+                        glyphon::Family::Serif
                     }
                 } else {
                     glyphon::Family::Serif
@@ -210,17 +188,11 @@ pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<
         if !sans_fallback.is_empty() {
             if let Some(ref cased) = resolved_sans {
                 glyphon::Family::Name(cased)
-            } else if !mono_fallback.is_empty() {
-                if let Some(ref cased_mono) = resolved_mono {
-                    glyphon::Family::Name(cased_mono)
-                } else {
-                    glyphon::Family::Name(&mono_fallback)
-                }
             } else {
-                glyphon::Family::Name(crate::layout::get_system_monospace_font())
+                glyphon::Family::SansSerif
             }
         } else {
-            glyphon::Family::Name(crate::layout::get_system_monospace_font())
+            glyphon::Family::SansSerif
         }
     };
     attrs = attrs.family(family);
@@ -756,8 +728,7 @@ pub fn push_plate_bevel_vertices(
 ) {
     let r = r.min(ww * 0.5).min(h * 0.5);
 
-    let light_angle = crate::layout::light_source_position();
-    let rad = light_angle.to_radians();
+    let rad = crate::layout::light_source_position();
     let lx = rad.cos();
     let ly = -rad.sin();
 
diff --git a/src/config.rs b/src/config.rs
index d65c7f3..d0e12cb 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -394,7 +394,7 @@ pub fn update_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str, value: &str,
     };
 
     if let Some(ref ext_ty) = existing_ty {
-        if ext_ty.starts_with("menu:") || ext_ty == "button" || ext_ty.starts_with("button:") || ext_ty == "vec2i" {
+        if ext_ty.starts_with("menu:") || ext_ty == "button" || ext_ty.starts_with("button:") || ext_ty == "vec2i" || ext_ty == "radian" {
             kdl_ty = Some(ext_ty.clone());
         }
     }
@@ -823,7 +823,9 @@ pub fn value_to_kdl_with_annotations(
                         let (val_str, val_ty) = match prop_val {
                             serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
                             serde_json::Value::Number(num) => {
-                                if num.is_f64() {
+                                if prop_name == "light_source_position" {
+                                    (num.to_string(), Some("radian".to_string()))
+                                } else if num.is_f64() {
                                     (num.to_string(), Some("f64".to_string()))
                                 } else {
                                     (num.to_string(), Some("i64".to_string()))
@@ -881,7 +883,9 @@ pub fn value_to_kdl_with_annotations(
             let (val_str, val_ty) = match val {
                 serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
                 serde_json::Value::Number(num) => {
-                    if num.is_f64() {
+                    if key == "light_source_position" {
+                        (num.to_string(), Some("radian".to_string()))
+                    } else if num.is_f64() {
                         (num.to_string(), Some("f64".to_string()))
                     } else {
                         (num.to_string(), Some("i64".to_string()))
diff --git a/src/layout.rs b/src/layout.rs
index e3bdb7c..1b39ecc 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1446,7 +1446,12 @@ pub fn set_toggle_height(height: f32) {
 }
 
 pub fn light_source_position() -> f32 {
-    get_style_registry().read().unwrap().get_float("light_source_position").unwrap_or(135.0)
+    let val = get_style_registry().read().unwrap().get_float("light_source_position").unwrap_or(2.3561945);
+    if val > 2.0 * std::f32::consts::PI {
+        val.to_radians()
+    } else {
+        val
+    }
 }
 
 pub fn bevel_depth() -> f32 {
@@ -5116,8 +5121,30 @@ pub struct VStack<'b, 'a, P> {
 }
 
 impl<'b, 'a, P: RenderTarget> VStack<'b, 'a, P> {
-    pub fn add_widget<T: Element + 'static>(&mut self, w: &mut T, ww: f32, wh: f32, ctx: &mut UiContext) {
-        self.context.widget(w, SectionContext::<P>::DEFAULT_MARGIN_X, ww, wh, ctx);
+    pub fn add_widget<T: Element + 'static>(&mut self, w: &mut T, _ww: f32, wh: f32, ctx: &mut UiContext) {
+        let pad = self.context.padding();
+        let margin_x = 2.0 * pad + 12.0;
+        let x = self.context.left + margin_x;
+        let clamped_w = (self.context.cw - 2.0 * margin_x).max(0.0);
+
+        let mut max_h = self.context.grid.max_height().max(self.context.content_y);
+        if max_h > self.context.content_start_y {
+            max_h += self.context.row_gap;
+        }
+        let y = max_h;
+
+        let pref_h = w.preferred_height().unwrap_or(wh);
+        let top_room = crate::widget::label_offset(w);
+        let total_h = pref_h + top_room;
+
+        w.set_row_rect(self.context.left + pad, self.context.cw - 2.0 * pad);
+        render_widget(self.context.pc, w, x, y, clamped_w, total_h, ctx);
+
+        let new_bottom = y + total_h;
+        self.context.content_y = new_bottom;
+        for h in &mut self.context.grid.col_heights {
+            *h = new_bottom;
+        }
         self.context.spacing(self.spacing);
     }
 
diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs
index 09089e8..5fdf72e 100644
--- a/src/widget/container/mod.rs
+++ b/src/widget/container/mod.rs
@@ -3,7 +3,6 @@ pub mod container_layout;
 pub mod section_container;
 pub mod header;
 pub mod content_bg;
-pub mod viewport_bg;
 pub mod parameters_bg;
 pub mod menu;
 pub mod breadcrumb;
@@ -28,7 +27,6 @@ pub use container_layout::{ContainerLayout, OverlayLayout, ManualLayout, Vertica
 pub use section_container::SectionContainer;
 pub use header::Header;
 pub use content_bg::ContentBg;
-pub use viewport_bg::ViewportBg;
 pub use parameters_bg::ParametersBg;
 pub use menu::{Menu, MenuBar};
 pub use breadcrumb::Breadcrumb;
diff --git a/src/widget/container/plate.rs b/src/widget/container/plate.rs
index 7589a17..b6f8166 100644
--- a/src/widget/container/plate.rs
+++ b/src/widget/container/plate.rs
@@ -239,7 +239,7 @@ impl Element for Plate {
         self.base.base.x = clamped_x;
         self.base.base.y = clamped_y;
         self.base.base.w = clamped_w;
-        self.base.base.h = clamped_h + label_off;
+        self.base.base.h = clamped_h;
 
         if !self.base.visible {
             return;
diff --git a/src/widget/container/section_container.rs b/src/widget/container/section_container.rs
index f669b35..f24abc5 100644
--- a/src/widget/container/section_container.rs
+++ b/src/widget/container/section_container.rs
@@ -10,6 +10,7 @@ pub struct SectionContainer {
     pub container: Container,
     pub base: Widget,
     pub parent: Option<*mut (dyn Element + 'static)>,
+    pub draw_children: bool,
 }
 
 impl SectionContainer {
@@ -19,6 +20,7 @@ impl SectionContainer {
             container: Container::new(),
             base: Widget::new(),
             parent: None,
+            draw_children: true,
         }
     }
 
@@ -27,6 +29,15 @@ impl SectionContainer {
         self.container = container.with_layout(layout);
         self
     }
+
+    pub fn with_draw_children(mut self, draw: bool) -> Self {
+        self.draw_children = draw;
+        self
+    }
+
+    pub fn set_draw_children(&mut self, draw: bool) {
+        self.draw_children = draw;
+    }
 }
 
 impl Default for SectionContainer {
@@ -114,35 +125,62 @@ impl Element for SectionContainer {
     }
 
     fn all_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        if !self.draw_children {
+            return Vec::new();
+        }
         let mut quads = self.header.all_quads(ctx);
         quads.extend(self.container.all_quads(ctx));
         quads
     }
 
     fn text_labels(&self) -> Vec<TextLabel> {
+        if !self.draw_children {
+            return Vec::new();
+        }
         let mut labels = self.header.text_labels();
         labels.extend(self.container.text_labels());
         labels
     }
 
     fn text_labels_with_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
+        if !self.draw_children {
+            return Vec::new();
+        }
         let mut labels = self.header.text_labels_with_bounds(ctx);
         labels.extend(self.container.text_labels_with_bounds(ctx));
         labels
     }
 
     fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+        if !self.draw_children {
+            return Vec::new();
+        }
         let mut labels = self.header.text_labels_with_font_and_bounds(ctx);
         labels.extend(self.container.text_labels_with_font_and_bounds(ctx));
         labels
     }
 
     fn get_text_items(&self) -> Vec<(&glyphon::Buffer, f32, f32, glyphon::Color)> {
+        if !self.draw_children {
+            return Vec::new();
+        }
         let mut items = self.header.get_text_items();
         items.extend(self.container.get_text_items());
         items
     }
 
+    fn all_rounded_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
+        if !self.visible() {
+            return Vec::new();
+        }
+        if !self.draw_children {
+            return self.header.all_rounded_quads(ctx);
+        }
+        let mut quads = self.header.all_rounded_quads(ctx);
+        quads.extend(self.container.all_rounded_quads(ctx));
+        quads
+    }
+
     fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
         self.header.hit_test(px, py, ctx) || self.container.hit_test(px, py, ctx)
     }
diff --git a/src/widget/container/viewport_bg.rs b/src/widget/container/viewport_bg.rs
deleted file mode 100644
index 94bde12..0000000
--- a/src/widget/container/viewport_bg.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-use crate::colors;
-use crate::widget::*;
-
-pub struct ViewportBg {
-    x: f32, y: f32, w: f32, h: f32,
-    hovered: bool,
-}
-
-impl ViewportBg {
-    pub fn new() -> Self { Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, hovered: false } }
-}
-
-impl Element for ViewportBg {
-    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] { colors::VIEWPORT_BG }
-    fn as_ptr(&self) -> *mut (dyn Element + 'static) {
-        self as *const Self as *mut Self as *mut (dyn Element + 'static)
-    }
-    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
-        self as *mut Self as *mut (dyn Element + 'static)
-    }
-    fn set_hovered(&mut self, v: bool) { self.hovered = v; }
-    fn hovered(&self) -> bool { self.hovered }
-    fn hit_test(&self, _px: f32, _py: f32, _ctx: &UiContext) -> bool { false }
-}
diff --git a/src/widget/display/mod.rs b/src/widget/display/mod.rs
index c0d9df4..7ed2505 100644
--- a/src/widget/display/mod.rs
+++ b/src/widget/display/mod.rs
@@ -42,3 +42,6 @@ pub use self::info_box::InfoBox;
 pub use self::status_dot::{DotStatus, StatusDot};
 pub use self::preview::{PreviewState, ImagePreviewData};
 pub use self::text_sizer::{measure_text_width, measure_text};
+pub mod viewport_3d;
+pub use self::viewport_3d::Viewport3D;
+
diff --git a/src/widget/display/viewport_3d.rs b/src/widget/display/viewport_3d.rs
new file mode 100644
index 0000000..a88cae8
--- /dev/null
+++ b/src/widget/display/viewport_3d.rs
@@ -0,0 +1,306 @@
+use crate::colors;
+use crate::widget::*;
+use glam::{Mat4, Vec3};
+
+#[derive(Debug, Clone)]
+pub struct Viewport3D {
+    base: Widget,
+    pub rotation_x: f32,
+    pub rotation_y: f32,
+    pub zoom: f32,
+    pub active_camera: String,
+    pub bg_color: [f32; 3],
+    pub grid_color: [f32; 3],
+    pub show_grid: bool,
+    pub show_cube: bool,
+    pub show_origin: bool,
+    pub show_camera_pivot: bool,
+
+    // Pending rotation to be consumed by the application when not using the default camera
+    pub pending_yaw: f32,
+    pub pending_pitch: f32,
+
+    // Modifiers state
+    ctrl_pressed: bool,
+    shift_pressed: bool,
+    alt_pressed: bool,
+
+    // Drag & scroll state tracking
+    is_rotating: bool,
+    is_zooming: bool,
+    scroll_lock: u8,
+    rotate_accum_yaw: f32,
+    rotate_accum_pitch: f32,
+    zoom_accum: f32,
+    rotate_velocity_yaw: f32,
+    rotate_velocity_pitch: f32,
+    zoom_velocity: f32,
+    last_rotate_time: std::time::Instant,
+    last_zoom_time: std::time::Instant,
+    pub scroll_speed: f32,
+    pub inertial_scroll: bool,
+    pub scroll_friction: f32,
+}
+
+impl Viewport3D {
+    pub fn new() -> Self {
+        let base = Widget::new();
+        Self {
+            base,
+            rotation_x: 0.0,
+            rotation_y: 0.0,
+            zoom: 1.0,
+            active_camera: "Default Camera".to_string(),
+            bg_color: [0.10, 0.10, 0.13],
+            grid_color: [0.18, 0.18, 0.22],
+            show_grid: true,
+            show_cube: true,
+            show_origin: true,
+            show_camera_pivot: true,
+            pending_yaw: 0.0,
+            pending_pitch: 0.0,
+            ctrl_pressed: false,
+            shift_pressed: false,
+            alt_pressed: false,
+            is_rotating: false,
+            is_zooming: false,
+            scroll_lock: 0,
+            rotate_accum_yaw: 0.0,
+            rotate_accum_pitch: 0.0,
+            zoom_accum: 0.0,
+            rotate_velocity_yaw: 0.0,
+            rotate_velocity_pitch: 0.0,
+            zoom_velocity: 0.0,
+            last_rotate_time: std::time::Instant::now(),
+            last_zoom_time: std::time::Instant::now(),
+            scroll_speed: 1.0,
+            inertial_scroll: true,
+            scroll_friction: 0.90,
+        }
+    }
+
+    pub fn with_scroll_speed(mut self, speed: f32) -> Self {
+        self.scroll_speed = speed;
+        self
+    }
+
+    pub fn with_inertial_scroll(mut self, enabled: bool) -> Self {
+        self.inertial_scroll = enabled;
+        self
+    }
+
+    pub fn with_scroll_friction(mut self, friction: f32) -> Self {
+        self.scroll_friction = friction;
+        self
+    }
+
+    pub fn reset_velocity(&mut self) {
+        self.rotate_velocity_yaw = 0.0;
+        self.rotate_velocity_pitch = 0.0;
+        self.zoom_velocity = 0.0;
+        self.is_rotating = false;
+        self.is_zooming = false;
+        self.scroll_lock = 0;
+    }
+
+    pub fn get_matrices(&self, aspect: f32, custom_camera_pos: Option<Vec3>, custom_camera_rot: Option<Vec3>, custom_pivot: Option<Vec3>) -> (Mat4, Mat4, Mat4) {
+        let camera_pos = custom_camera_pos.unwrap_or(Vec3::new(2.5, 1.8, 2.5));
+        let pivot = custom_pivot.unwrap_or(Vec3::ZERO);
+        let rot = custom_camera_rot.unwrap_or(Vec3::ZERO);
+        let rx = rot.x;
+        let ry = rot.y;
+        let rz = rot.z;
+
+        let base_offset = camera_pos - pivot;
+        let distance = base_offset.length();
+        let yaw0 = base_offset.x.atan2(base_offset.z);
+        let pitch0 = (base_offset.y / distance.max(1e-5)).asin();
+
+        let total_ry = ry.to_radians() + yaw0;
+        let total_rx = rx.to_radians() + pitch0;
+
+        let view_rot_pos = Mat4::from_rotation_y(total_ry) * Mat4::from_rotation_x(-total_rx);
+        let camera_up = view_rot_pos.transform_vector3(Vec3::Y);
+        let camera_world_pos = pivot + view_rot_pos.transform_vector3(Vec3::new(0.0, 0.0, distance) * self.zoom);
+        let view_mat = Mat4::from_rotation_z(rz.to_radians()) * Mat4::look_at_rh(camera_world_pos, pivot, camera_up);
+
+        let model = Mat4::from_rotation_y(self.rotation_y) * Mat4::from_rotation_x(self.rotation_x);
+        let proj = Mat4::perspective_rh(0.9, aspect, 0.1, 100.0);
+
+        (proj, view_mat, model)
+    }
+}
+
+impl Element for Viewport3D {
+    crate::impl_widget_base!(Viewport3D);
+
+    fn color(&self) -> [f32; 4] {
+        colors::VIEWPORT_BG
+    }
+
+    fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) {
+        self.ctrl_pressed = ctrl;
+        self.shift_pressed = shift;
+        self.alt_pressed = alt;
+    }
+
+    fn hit_test(&self, px: f32, py: f32, _ctx: &UiContext) -> bool {
+        let (x, y, w, h) = self.rect();
+        px >= x && px <= x + w && py >= y && py <= y + h
+    }
+
+    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, _px: f32, _py: f32, _ctx: &mut UiContext) -> bool {
+        let scale = crate::scale::scale_factor();
+        if self.ctrl_pressed {
+            match delta {
+                MouseScrollDelta::LineDelta(_x, y) => {
+                    let dy = *y * 0.15 * self.scroll_speed;
+                    self.zoom *= (-dy).exp();
+                    self.zoom = self.zoom.clamp(0.05, 20.0);
+                    self.is_zooming = false;
+                    
+                    let dt = 0.016;
+                    self.zoom_velocity = self.zoom_velocity * 0.4 + (dy / dt) * 0.6;
+                    true
+                }
+                MouseScrollDelta::PixelDelta(pos) => {
+                    let dy = (pos.y as f32 / scale) * 0.005 * self.scroll_speed;
+                    self.zoom *= (-dy).exp();
+                    self.zoom = self.zoom.clamp(0.05, 20.0);
+                    self.is_zooming = true;
+                    self.last_zoom_time = std::time::Instant::now();
+                    self.zoom_accum += dy;
+                    true
+                }
+            }
+        } else {
+            match delta {
+                MouseScrollDelta::LineDelta(x, y) => {
+                    self.scroll_lock = 0;
+                    let dx = *x * 0.05 * self.scroll_speed;
+                    let dy = *y * 0.05 * self.scroll_speed;
+                    
+                    if self.active_camera != "Default Camera" {
+                        self.pending_yaw += dx;
+                        self.pending_pitch += -dy;
+                    } else {
+                        self.rotation_y += dx;
+                        self.rotation_x -= dy;
+                    }
+                    
+                    self.is_rotating = false;
+                    let dt = 0.016;
+                    self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + (dx / dt) * 0.6;
+                    self.rotate_velocity_pitch = self.rotate_velocity_pitch * 0.4 + (-dy / dt) * 0.6;
+                    true
+                }
+                MouseScrollDelta::PixelDelta(pos) => {
+                    let mut dx = (pos.x as f32 / scale) * 0.005 * self.scroll_speed;
+                    let mut dy = (pos.y as f32 / scale) * 0.005 * self.scroll_speed;
+
+                    self.rotate_accum_yaw += dx;
+                    self.rotate_accum_pitch -= dy;
+                    if self.scroll_lock == 0 {
+                        if self.rotate_accum_yaw.abs() > 0.002 || self.rotate_accum_pitch.abs() > 0.002 {
+                            if self.rotate_accum_pitch.abs() > 1.2 * self.rotate_accum_yaw.abs() {
+                                self.scroll_lock = 2;
+                            } else if self.rotate_accum_yaw.abs() > 1.2 * self.rotate_accum_pitch.abs() {
+                                self.scroll_lock = 1;
+                            }
+                        }
+                    } else {
+                        if self.scroll_lock == 1 {
+                            dy = 0.0;
+                        } else {
+                            dx = 0.0;
+                        }
+                    }
+
+                    if self.active_camera != "Default Camera" {
+                        self.pending_yaw += dx;
+                        self.pending_pitch += -dy;
+                    } else {
+                        self.rotation_y += dx;
+                        self.rotation_x -= dy;
+                    }
+
+                    self.is_rotating = true;
+                    self.last_rotate_time = std::time::Instant::now();
+                    true
+                }
+            }
+        }
+    }
+
+    fn tick(&mut self, dt: f32, _ctx: &mut UiContext) -> bool {
+        let now = std::time::Instant::now();
+        let mut changed = false;
+
+        if self.is_rotating {
+            if now.duration_since(self.last_rotate_time).as_secs_f32() > 0.05 {
+                self.is_rotating = false;
+                self.scroll_lock = 0;
+            } else if dt > 1e-5 {
+                let vel_yaw = self.rotate_accum_yaw / dt;
+                let vel_pitch = self.rotate_accum_pitch / dt;
+                self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + vel_yaw * 0.6;
+                self.rotate_velocity_pitch = self.rotate_velocity_pitch * 0.4 + vel_pitch * 0.6;
+            }
+            self.rotate_accum_yaw = 0.0;
+            self.rotate_accum_pitch = 0.0;
+        }
+
+        if self.is_zooming {
+            if now.duration_since(self.last_zoom_time).as_secs_f32() > 0.05 {
+                self.is_zooming = false;
+            } else if dt > 1e-5 {
+                let vel_zoom = self.zoom_accum / dt;
+                self.zoom_velocity = self.zoom_velocity * 0.4 + vel_zoom * 0.6;
+            }
+            self.zoom_accum = 0.0;
+        }
+
+        if !self.is_rotating && (self.rotate_velocity_yaw.abs() > 0.001 || self.rotate_velocity_pitch.abs() > 0.001) {
+            if !self.inertial_scroll {
+                self.rotate_velocity_yaw = 0.0;
+                self.rotate_velocity_pitch = 0.0;
+            } else {
+                let dx = self.rotate_velocity_yaw * dt;
+                let dy = self.rotate_velocity_pitch * dt;
+                
+                if self.active_camera != "Default Camera" {
+                    self.pending_yaw += dx;
+                    self.pending_pitch += dy;
+                } else {
+                    self.rotation_y += dx;
+                    self.rotation_x += dy;
+                }
+
+                let decay = self.scroll_friction.powf(dt * 60.0);
+                self.rotate_velocity_yaw *= decay;
+                self.rotate_velocity_pitch *= decay;
+
+                if self.rotate_velocity_yaw.abs() < 0.01 { self.rotate_velocity_yaw = 0.0; }
+                if self.rotate_velocity_pitch.abs() < 0.01 { self.rotate_velocity_pitch = 0.0; }
+                changed = true;
+            }
+        }
+
+        if !self.is_zooming && self.zoom_velocity.abs() > 0.001 {
+            if !self.inertial_scroll {
+                self.zoom_velocity = 0.0;
+            } else {
+                let d_zoom = self.zoom_velocity * dt;
+                self.zoom *= (-d_zoom).exp();
+                self.zoom = self.zoom.clamp(0.05, 20.0);
+
+                let decay = self.scroll_friction.powf(dt * 60.0);
+                self.zoom_velocity *= decay;
+                if self.zoom_velocity.abs() < 0.01 { self.zoom_velocity = 0.0; }
+                changed = true;
+            }
+        }
+
+        changed
+    }
+}
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index ef520fb..2599d41 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -239,7 +239,7 @@ impl Element for Dropdown {
         self.base.x = x;
         self.base.y = y;
         self.base.w = w;
-        self.base.h = h + self.base.label_offset();
+        self.base.h = h;
     }
 
     fn get_value_string(&self) -> Option<String> {
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index f6b31af..dde0911 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -117,7 +117,7 @@ impl Element for Slider {
         self.base.x = x;
         self.base.y = y;
         self.base.w = w;
-        self.base.h = h + self.base.label_offset();
+        self.base.h = h;
     }
 
     fn widget_font(&self) -> Option<String> {
@@ -617,7 +617,7 @@ impl Element for RangeSlider {
         self.base.x = x;
         self.base.y = y;
         self.base.w = w;
-        self.base.h = h + self.base.label_offset();
+        self.base.h = h;
     }
 
     fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] }
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index b319ef9..00c3829 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -82,8 +82,8 @@ impl Element for Spinbox {
         self.base.x = x;
         self.base.y = y;
         self.base.w = w;
-        self.base.h = h + self.base.label_offset();
-    }
+        self.base.h = h;
+     }
 
     fn get_value_string(&self) -> Option<String> {
         if self.decimals > 0 {
diff --git a/src/widget/input/trackpad.rs b/src/widget/input/trackpad.rs
index 33a5fcc..734fa39 100644
--- a/src/widget/input/trackpad.rs
+++ b/src/widget/input/trackpad.rs
@@ -50,7 +50,7 @@ impl Element for Trackpad {
         self.base.x = x;
         self.base.y = y;
         self.base.w = w;
-        self.base.h = h + self.label_offset();
+        self.base.h = h;
     }
 
     fn color(&self) -> [f32; 4] {
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 2011bad..2e8e43e 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -729,7 +729,7 @@ pub use self::input::{
 pub use self::container::{
     Container, ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,
     ColumnsLayout, MosaicLayout, ReverseMosaicLayout,
-    SectionContainer, Header, ContentBg, ViewportBg, ParametersBg, List,
+    SectionContainer, Header, ContentBg, ParametersBg, List,
     ScrollBox, Menu, MenuBar, Spreadsheet, Breadcrumb, Plate,
     Switcher, Layer, Page, Backplate, Paginator, ScrollBar, TreeList, TreeElement, ControlPanel
 };
@@ -737,7 +737,8 @@ pub use self::display::{
     TextLabel, Label, SectionHeader, StyledLabel, TextItem, Svg, UsageBar,
     LayoutPreview, FontPreview, InfoBox, StatusDot, InteractiveListItem,
     GraphNode, Graph, Float3, ProgressBar, StatusBar, Splitter, Node, Separator,
-    DotStatus, PreviewLayoutMode, Sidebar, Panel, PreviewState, ImagePreviewData, serialize_widgets
+    DotStatus, PreviewLayoutMode, Sidebar, Panel, PreviewState, ImagePreviewData, serialize_widgets,
+    Viewport3D
 };
 
 pub trait PageSelector {