diff --git a/.gitignore b/.gitignore index ea8c4bf..86fd8e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,10 @@ /target +JE-Skin/ +eskin-finger-sdk/ +*.err +*.out +*.exe +*.pdb +*.d +*.rlib +*.rmeta diff --git a/JE-Skin b/JE-Skin deleted file mode 160000 index 59e9203..0000000 --- a/JE-Skin +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 59e920336315d61e5dcaa31224db97e491317ce4 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f69904f --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Eskin Model Player + +实时压力矩阵可视化桌面应用,用于连接 E-Skin 传感器设备并通过串口接收压力数据,以热力图方式实时渲染。 + +## 功能 + +- 串口连接 E-Skin 传感器(921600 baud) +- 实时压力矩阵热力图渲染(wgpu) +- 自定义无边框窗口,macOS 风格标题栏 +- 浮动面板:连接管理、场景视图、配置、数据统计 + +## 依赖 + +- Rust 2024 edition +- [eframe](https://crates.io/crates/eframe) 0.34(egui + wgpu) +- [serialport](https://crates.io/crates/serialport) 4.9 + +## 构建与运行 + +```bash +cargo run --release +``` + +## 项目结构 + +``` +src/ +├── main.rs # 入口,创建 eframe 窗口 +├── app.rs # 应用主循环与面板调度 +├── connection.rs # 串口连接管理(后台线程) +├── serial_core/ # 串口协议编解码 +│ ├── serial.rs # 串口读写循环 +│ ├── codec.rs # 帧编解码器 +│ ├── frame.rs # 帧结构定义 +│ └── ... +├── render.rs # wgpu 渲染管线(背景 + 数字叠加) +├── matrix.rs # 矩阵布局与坐标变换 +├── ui.rs # egui 浮动面板 UI +├── theme.rs # 深色工程主题 +├── shader.wgsl # WGSL 着色器 +└── utils.rs # 工具函数 +``` diff --git a/eskin-finger-sdk b/eskin-finger-sdk deleted file mode 160000 index aa1b312..0000000 --- a/eskin-finger-sdk +++ /dev/null @@ -1 +0,0 @@ -Subproject commit aa1b312290eaceba3cb383469dd0013b7f87597c diff --git a/src/app.rs b/src/app.rs index 83d3f7f..3d45daf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,9 @@ use crate::connection::ConnectionManager; use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme}; use crate::{ matrix::{MATRIX_COLS, MATRIX_ROWS}, - render::{BackgroundRenderResources, PRESSURE_CELL_COUNT, WgpuBackgroundCallback}, + render::{ + BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback, + }, ui::{ ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel, draw_connect_panel, draw_scene_panel, draw_stats_panel, @@ -18,7 +20,7 @@ pub struct EskinDesktopApp { connect_panel: FloatingPanelState, connect_state: ConnectPanelState, connection: Arc, - pressure_matrix: [f32; PRESSURE_CELL_COUNT], + pressure_matrix: PressureFrame, data_log_frame: u64, scene_panel: FloatingPanelState, config_panel: FloatingPanelState, @@ -51,7 +53,7 @@ impl EskinDesktopApp { connect_panel: FloatingPanelState::new([0.0, 0.0], [0.0, 0.0]), connect_state: ConnectPanelState::default(), connection: Arc::new(ConnectionManager::new()), - pressure_matrix: [0.0; PRESSURE_CELL_COUNT], + pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT], data_log_frame: 0, scene_panel: FloatingPanelState::new([16.0, 48.0], [16.0, 48.0]), config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]), @@ -92,15 +94,115 @@ impl EskinDesktopApp { } } - fn draw_toolbar(&mut self, ui: &mut egui::Ui) { - egui::Panel::top("main_menu").show_inside(ui, |ui| { - ui.horizontal(|ui| { - ui.checkbox(&mut self.connect_panel.visible, "连接"); - ui.checkbox(&mut self.scene_panel.visible, "场景"); - ui.checkbox(&mut self.config_panel.visible, "配置"); - ui.checkbox(&mut self.stats_panel.visible, "统计"); - }); - }); + fn draw_title_bar(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let title_bar_height = 36.0; + let title_bar_rect = ui + .allocate_space(egui::vec2(ui.available_width(), title_bar_height)) + .1; + + // Paint background + ui.painter().rect_filled( + title_bar_rect, + egui::CornerRadius::ZERO, + ENGINEERING_DARK.panel_deep, + ); + ui.painter().line_segment( + [title_bar_rect.left_bottom(), title_bar_rect.right_bottom()], + egui::Stroke::new(1.0, ENGINEERING_DARK.border), + ); + + // Drag-to-move: double-click to maximize, drag to move + let title_bar_response = ui.interact( + title_bar_rect, + egui::Id::new("title_bar"), + egui::Sense::click_and_drag(), + ); + if title_bar_response.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if title_bar_response.double_clicked() { + let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized)); + } + + // macOS traffic light buttons (close / minimize / maximize) + let btn_size = 14.0; + let btn_spacing = 8.0; + let btns_start = title_bar_rect.left_center() + egui::vec2(12.0, 0.0); + + let btn_close_center = btns_start; + let btn_min_center = btns_start + egui::vec2(btn_size + btn_spacing, 0.0); + let btn_max_center = btns_start + egui::vec2((btn_size + btn_spacing) * 2.0, 0.0); + + // Close (red) + let close_rect = + egui::Rect::from_center_size(btn_close_center, egui::vec2(btn_size, btn_size)); + let close_resp = ui.interact(close_rect, egui::Id::new("btn_close"), egui::Sense::click()); + let close_color = egui::Color32::from_rgb(255, 95, 86); + ui.painter() + .circle_filled(btn_close_center, btn_size / 2.0, close_color); + // Draw × when hovered + if close_resp.hovered() { + ui.painter().line_segment( + [ + btn_close_center + egui::vec2(-3.0, -3.0), + btn_close_center + egui::vec2(3.0, 3.0), + ], + egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)), + ); + ui.painter().line_segment( + [ + btn_close_center + egui::vec2(3.0, -3.0), + btn_close_center + egui::vec2(-3.0, 3.0), + ], + egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)), + ); + } + if close_resp.clicked() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + + // Minimize (yellow) + let min_rect = egui::Rect::from_center_size(btn_min_center, egui::vec2(btn_size, btn_size)); + let min_resp = ui.interact(min_rect, egui::Id::new("btn_min"), egui::Sense::click()); + let min_color = egui::Color32::from_rgb(255, 189, 46); + ui.painter() + .circle_filled(btn_min_center, btn_size / 2.0, min_color); + if min_resp.hovered() { + ui.painter().line_segment( + [ + btn_min_center + egui::vec2(-3.0, 0.0), + btn_min_center + egui::vec2(3.0, 0.0), + ], + egui::Stroke::new(1.5, egui::Color32::from_rgb(120, 80, 0)), + ); + } + if min_resp.clicked() { + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + } + + // Maximize (green) + let max_rect = egui::Rect::from_center_size(btn_max_center, egui::vec2(btn_size, btn_size)); + let max_resp = ui.interact(max_rect, egui::Id::new("btn_max"), egui::Sense::click()); + let max_color = egui::Color32::from_rgb(39, 201, 63); + ui.painter() + .circle_filled(btn_max_center, btn_size / 2.0, max_color); + if max_resp.hovered() { + let s = 3.0; + ui.painter().rect_stroke( + egui::Rect::from_center_size(btn_max_center, egui::vec2(s * 2.0, s * 2.0)), + egui::CornerRadius::same(1), + egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 80, 10)), + egui::StrokeKind::Outside, + ); + } + if max_resp.clicked() { + let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized)); + } } fn draw_floating_panels(&mut self, ctx: &egui::Context) { @@ -133,18 +235,11 @@ fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) { ); } -fn normalize_pressure_sample( - raw: &[u32], - rows: u32, - cols: u32, - normalized: &mut [f32; PRESSURE_CELL_COUNT], -) { - normalized.fill(0.0); - let max_value = raw.iter().copied().max().unwrap_or(0); +fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) { + const RANGE_MIN: f32 = 0.0; + const RANGE_MAX: f32 = 7000.0; - if max_value == 0 { - return; - } + normalized.fill([0.0, 0.0]); let src_cols = cols.max(1); let copy_rows = MATRIX_ROWS.min(rows); @@ -155,18 +250,25 @@ fn normalize_pressure_sample( let src_index = (row * src_cols + col) as usize; let dst_index = (row * MATRIX_COLS + col) as usize; if let Some(value) = raw.get(src_index) { - normalized[dst_index] = (*value as f32 / max_value as f32).clamp(0.0, 1.0); + let raw_value = *value as f32; + let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0); + let display_value = if raw_value <= RANGE_MIN + 4.0 { + 0.0 + } else { + raw_value.round().min(9999.0) + }; + normalized[dst_index] = [mapped, display_value]; } } } } impl eframe::App for EskinDesktopApp { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); self.draw_wgpu_background(ui); - self.draw_toolbar(ui); + self.draw_title_bar(ui, frame); self.draw_floating_panels(&ctx); // Keep repainting while the wgpu background is a realtime viewport. diff --git a/src/connection.rs b/src/connection.rs index cb070f2..a275750 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -4,7 +4,7 @@ use std::time::Duration; use crossbeam_channel::{self, Receiver, Sender, TryRecvError}; -use crate::serial_core::serial::{run_serial_loop, SerialPortReadWrite}; +use crate::serial_core::serial::{SerialPortReadWrite, run_serial_loop}; /// Connection state visible to the UI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -115,11 +115,7 @@ impl ConnectionManager { let rows = 12u32; let cols = 7u32; let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect(); - last = Some(PressureSample { - matrix, - rows, - cols, - }); + last = Some(PressureSample { matrix, rows, cols }); } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => break, @@ -174,11 +170,7 @@ fn run_device_loop( Ok(vals) => { // Store latest let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect(); - *latest.lock().unwrap() = Some(PressureSample { - matrix, - rows, - cols, - }); + *latest.lock().unwrap() = Some(PressureSample { matrix, rows, cols }); // Forward let _ = outer_tx.try_send(vals); } @@ -194,4 +186,4 @@ fn run_device_loop( let _ = bridge_handle.join(); Ok(()) -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 2bc0274..f70ad64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,8 @@ fn main() -> eframe::Result<()> { renderer: eframe::Renderer::Wgpu, viewport: egui::ViewportBuilder::default() .with_inner_size([1920.0, 1080.0]) - .with_min_inner_size([1280.0, 720.0]), + .with_min_inner_size([1280.0, 720.0]) + .with_decorations(false), ..Default::default() }; diff --git a/src/matrix.rs b/src/matrix.rs index 5f88cdf..03650fa 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -11,8 +11,6 @@ const MIN_BOARD_PADDING: f32 = 2.6; const MAX_BOARD_PADDING: f32 = 6.8; const MATRIX_OFFSET_Y: f32 = -2.4; const MATRIX_OFFSET_Z: f32 = 12.0; -const HEIGHT_SCALE: f32 = 10.6; -const BASE_HEIGHT: f32 = 0.12; const CAMERA_FOV: f32 = 36.0; const CAMERA_DISTANCE_MIN: f32 = 30.0; const CAMERA_DISTANCE_MAX: f32 = 122.0; @@ -87,14 +85,13 @@ pub fn glyph_world_position( pressure: f32, ) -> ([f32; 4], f32) { let normalized = pressure.clamp(0.0, 1.0); - let height = BASE_HEIGHT + normalized.powf(0.9) * HEIGHT_SCALE; let x = (col as f32 - cols as f32 / 2.0 + 0.5) * layout.cell_spacing; let z = (row as f32 - rows as f32 / 2.0 + 0.5) * layout.cell_spacing; ( [ x, - MATRIX_OFFSET_Y + height + layout.label_float_offset, + MATRIX_OFFSET_Y + layout.label_float_offset, MATRIX_OFFSET_Z + z, 1.0, ], diff --git a/src/render.rs b/src/render.rs index 657adf5..68fb01c 100644 --- a/src/render.rs +++ b/src/render.rs @@ -8,11 +8,12 @@ use crate::matrix::{MatrixLayout, build_view_projection, glyph_world_position}; pub const PRESSURE_CELL_COUNT: usize = (crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize; +pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT]; pub struct WgpuBackgroundCallback { pub width: f32, pub height: f32, - pub pressure: [f32; PRESSURE_CELL_COUNT], + pub pressure: PressureFrame, } impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback { @@ -126,7 +127,8 @@ impl BackgroundRenderResources { usage: wgpu::BufferUsages::VERTEX, }); - let glyph_instances = build_glyph_instances(rows, cols, &layout, &[]); + let glyph_instances = + build_glyph_instances(rows, cols, &layout, &[[0.0, 0.0]; PRESSURE_CELL_COUNT]); let glyph_instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Pressure Glyph Instance Buffer"), contents: bytemuck::cast_slice(&glyph_instances), @@ -148,7 +150,7 @@ impl BackgroundRenderResources { } } - fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &[f32]) { + fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &PressureFrame) { let aspect = width / height.max(1.0); self.uniform = MatrixUniform::new(width, height, build_view_projection(aspect, &self.layout)); @@ -255,19 +257,19 @@ fn build_glyph_instances( rows: u32, cols: u32, layout: &MatrixLayout, - pressure: &[f32], + pressure: &PressureFrame, ) -> Vec { let mut instances = Vec::with_capacity((rows * cols) as usize); for row in 0..rows { for col in 0..cols { let index = (row * cols + col) as usize; - let normalized = pressure.get(index).copied().unwrap_or(0.0); + let [normalized, display_value] = pressure.get(index).copied().unwrap_or([0.0, 0.0]); let (world_position, normalized) = glyph_world_position(row, col, rows, cols, layout, normalized); instances.push(GlyphInstance { world_position, - style: [normalized, 0.0, 0.0, 0.0], + style: [normalized, display_value, 0.0, 0.0], }); } } @@ -280,17 +282,17 @@ fn update_glyph_instances( rows: u32, cols: u32, layout: &MatrixLayout, - pressure: &[f32], + pressure: &PressureFrame, ) { for row in 0..rows { for col in 0..cols { let index = (row * cols + col) as usize; - let normalized = pressure.get(index).copied().unwrap_or(0.0); + let [normalized, display_value] = pressure.get(index).copied().unwrap_or([0.0, 0.0]); let (world_position, normalized) = glyph_world_position(row, col, rows, cols, layout, normalized); if let Some(instance) = instances.get_mut(index) { instance.world_position = world_position; - instance.style = [normalized, 0.0, 0.0, 0.0]; + instance.style = [normalized, display_value, 0.0, 0.0]; } } } diff --git a/src/serial_core/codec.rs b/src/serial_core/codec.rs index d47430a..77520a5 100644 --- a/src/serial_core/codec.rs +++ b/src/serial_core/codec.rs @@ -4,4 +4,4 @@ use std::time::Instant; pub trait Codec { fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result, CodecError>; fn encode(&self, frame: &F) -> Result, CodecError>; -} \ No newline at end of file +} diff --git a/src/serial_core/codecs/mod.rs b/src/serial_core/codecs/mod.rs index 9538446..3de578d 100644 --- a/src/serial_core/codecs/mod.rs +++ b/src/serial_core/codecs/mod.rs @@ -1 +1 @@ -pub mod tactile_a; \ No newline at end of file +pub mod tactile_a; diff --git a/src/serial_core/codecs/tactile_a.rs b/src/serial_core/codecs/tactile_a.rs index b13d171..6e84d4f 100644 --- a/src/serial_core/codecs/tactile_a.rs +++ b/src/serial_core/codecs/tactile_a.rs @@ -30,11 +30,7 @@ impl TactileACodec { .chunks_exact(2) .map(|chunk| { let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32; - if raw < 15 { - 0 - } else { - raw - } + if raw < 15 { 0 } else { raw } }) .collect::>(); @@ -185,4 +181,4 @@ impl Codec for TactileACodec { _ => Err(CodecError::InvalidFrameType), } } -} \ No newline at end of file +} diff --git a/src/serial_core/error.rs b/src/serial_core/error.rs index ef4f2d9..2875b83 100644 --- a/src/serial_core/error.rs +++ b/src/serial_core/error.rs @@ -48,4 +48,4 @@ impl fmt::Display for CodecError { } } -impl std::error::Error for CodecError {} \ No newline at end of file +impl std::error::Error for CodecError {} diff --git a/src/serial_core/frame.rs b/src/serial_core/frame.rs index b979fe2..0bec399 100644 --- a/src/serial_core/frame.rs +++ b/src/serial_core/frame.rs @@ -33,4 +33,4 @@ pub enum TactileAFrameStatusCode { pub enum TactileAFrame { Req(TactileAReqFrame), Rep(TactileARepFrame), -} \ No newline at end of file +} diff --git a/src/serial_core/mod.rs b/src/serial_core/mod.rs index 7774db1..05940a0 100644 --- a/src/serial_core/mod.rs +++ b/src/serial_core/mod.rs @@ -3,4 +3,4 @@ pub mod codecs; pub mod error; pub mod frame; pub mod serial; -pub mod utils; \ No newline at end of file +pub mod utils; diff --git a/src/serial_core/serial.rs b/src/serial_core/serial.rs index 19cdfbe..8a97104 100644 --- a/src/serial_core/serial.rs +++ b/src/serial_core/serial.rs @@ -93,4 +93,4 @@ impl ReadWrite for SerialPortReadWrite { fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { self.inner.write_all(buf) } -} \ No newline at end of file +} diff --git a/src/serial_core/utils.rs b/src/serial_core/utils.rs index 9e5e264..ece818f 100644 --- a/src/serial_core/utils.rs +++ b/src/serial_core/utils.rs @@ -7,4 +7,4 @@ pub fn calc_crc8_itu(c: &[u8]) -> u8 { pub fn elapsed_millis(start_at: Instant) -> u64 { start_at.elapsed().as_millis() as u64 -} \ No newline at end of file +} diff --git a/src/shader.wgsl b/src/shader.wgsl index 44867a0..1ecbf14 100755 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -25,6 +25,7 @@ struct GlyphVertexOutput { @builtin(position) clip_position: vec4f, @location(0) local: vec2f, @location(1) intensity: f32, + @location(2) display_value: f32, } fn saturate(value: f32) -> f32 { @@ -43,25 +44,27 @@ fn rotate2(point: vec2f, angle: f32) -> vec2f { return vec2f(point.x * c - point.y * s, point.x * s + point.y * c); } +fn rect_alpha(point: vec2f, center: vec2f, half_size: vec2f) -> f32 { + let delta = abs(point - center) - half_size; + let outside = length(max(delta, vec2f(0.0, 0.0))); + let inside = min(max(delta.x, delta.y), 0.0); + let dist = outside + inside; + return 1.0 - smoothstep(0.015, 0.045, dist); +} + fn range_stop_color(index: u32) -> vec3f { switch index { case 0u: { - return vec3f(0.263, 0.667, 0.420); + return vec3f(0.140, 0.690, 0.890); } case 1u: { - return vec3f(0.094, 0.735, 0.875); + return vec3f(0.250, 0.760, 0.380); } case 2u: { - return vec3f(0.298, 0.349, 0.957); - } - case 3u: { - return vec3f(0.812, 0.294, 0.851); - } - case 4u: { - return vec3f(0.957, 0.337, 0.333); + return vec3f(1.000, 0.670, 0.180); } default: { - return vec3f(1.000, 0.596, 0.204); + return vec3f(1.000, 0.255, 0.190); } } } @@ -69,43 +72,115 @@ fn range_stop_color(index: u32) -> vec3f { fn sample_range_color(value: f32) -> vec3f { let t = saturate(value); - if (t <= 0.25) { - let local = smoothstep(0.0, 0.25, t); + if (t <= 0.33) { + let local = smoothstep(0.0, 0.33, t); return mix(range_stop_color(0u), range_stop_color(1u), local); } - if (t <= 0.5) { - let local = smoothstep(0.25, 0.5, t); + if (t <= 0.66) { + let local = smoothstep(0.33, 0.66, t); return mix(range_stop_color(1u), range_stop_color(2u), local); } - if (t <= 0.75) { - let local = smoothstep(0.5, 0.75, t); - return mix(range_stop_color(2u), range_stop_color(3u), local); - } - - if (t <= 0.875) { - let local = smoothstep(0.75, 0.875, t); - return mix(range_stop_color(3u), range_stop_color(4u), local); - } - - let local = smoothstep(0.875, 1.0, t); - return mix(range_stop_color(4u), range_stop_color(5u), local); + let local = smoothstep(0.66, 1.0, t); + return mix(range_stop_color(2u), range_stop_color(3u), local); } -fn digit_zero_alpha(local: vec2f) -> f32 { - let tilted = rotate2(local, -0.18); - let oval = vec2f(tilted.x / 0.48, tilted.y / 0.75); - let aa = 0.030; - let outer = 1.0 - smoothstep(0.93 - aa, 0.93 + aa, length(oval)); - let inner = 1.0 - smoothstep(0.57 - aa, 0.57 + aa, length(oval)); - let ring = saturate(outer - inner); - let slash = 1.0 - smoothstep( - 0.036, - 0.066, - capsule_distance(tilted, vec2f(-0.16, 0.43), vec2f(0.18, -0.43)), - ); - return max(ring, slash * 0.88); +fn digit_segment_on(digit: u32, segment: u32) -> bool { + switch digit { + case 0u: { return segment != 6u; } + case 1u: { return segment == 1u || segment == 2u; } + case 2u: { return segment == 0u || segment == 1u || segment == 6u || segment == 4u || segment == 3u; } + case 3u: { return segment == 0u || segment == 1u || segment == 6u || segment == 2u || segment == 3u; } + case 4u: { return segment == 5u || segment == 6u || segment == 1u || segment == 2u; } + case 5u: { return segment == 0u || segment == 5u || segment == 6u || segment == 2u || segment == 3u; } + case 6u: { return segment == 0u || segment == 5u || segment == 4u || segment == 3u || segment == 2u || segment == 6u; } + case 7u: { return segment == 0u || segment == 1u || segment == 2u; } + case 8u: { return true; } + default: { return segment == 0u || segment == 1u || segment == 2u || segment == 3u || segment == 5u || segment == 6u; } + } +} + +fn seven_segment_digit_alpha(local: vec2f, digit: u32) -> f32 { + var alpha = 0.0; + if (digit_segment_on(digit, 0u)) { + alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.70), vec2f(0.38, 0.078))); + } + if (digit_segment_on(digit, 1u)) { + alpha = max(alpha, rect_alpha(local, vec2f(0.39, 0.36), vec2f(0.078, 0.335))); + } + if (digit_segment_on(digit, 2u)) { + alpha = max(alpha, rect_alpha(local, vec2f(0.39, -0.36), vec2f(0.078, 0.335))); + } + if (digit_segment_on(digit, 3u)) { + alpha = max(alpha, rect_alpha(local, vec2f(0.0, -0.70), vec2f(0.38, 0.078))); + } + if (digit_segment_on(digit, 4u)) { + alpha = max(alpha, rect_alpha(local, vec2f(-0.39, -0.36), vec2f(0.078, 0.335))); + } + if (digit_segment_on(digit, 5u)) { + alpha = max(alpha, rect_alpha(local, vec2f(-0.39, 0.36), vec2f(0.078, 0.335))); + } + if (digit_segment_on(digit, 6u)) { + alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.0), vec2f(0.35, 0.075))); + } + return alpha; +} + +fn digit_count(value: u32) -> u32 { + if (value >= 1000u) { + return 4u; + } + if (value >= 100u) { + return 3u; + } + if (value >= 10u) { + return 2u; + } + return 1u; +} + +fn digit_at(value: u32, slot: u32, count: u32) -> u32 { + if (count == 4u) { + switch slot { + case 0u: { return (value / 1000u) % 10u; } + case 1u: { return (value / 100u) % 10u; } + case 2u: { return (value / 10u) % 10u; } + default: { return value % 10u; } + } + } + if (count == 3u) { + switch slot { + case 0u: { return (value / 100u) % 10u; } + case 1u: { return (value / 10u) % 10u; } + default: { return value % 10u; } + } + } + if (count == 2u) { + return select(value % 10u, (value / 10u) % 10u, slot == 0u); + } + return value % 10u; +} + +fn number_alpha(local: vec2f, display_value: f32) -> f32 { + let value = min(u32(max(display_value + 0.5, 0.0)), 9999u); + let count = digit_count(value); + let count_f = f32(count); + let slot_width = 1.74 / count_f; + let start_x = -slot_width * (count_f - 1.0) * 0.5; + var alpha = 0.0; + + for (var slot = 0u; slot < 4u; slot = slot + 1u) { + if (slot < count) { + let center_x = start_x + f32(slot) * slot_width; + let digit_local = vec2f((local.x - center_x) / (slot_width * 0.78), local.y / 0.92); + let digit = digit_at(value, slot, count); + let in_slot = step(abs(local.x - center_x), slot_width * 0.48); + alpha = max(alpha, seven_segment_digit_alpha(digit_local, digit) * in_slot); + } + } + + return alpha; } @vertex @@ -149,8 +224,8 @@ fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f { let tick_area = step(abs(local.x), half_size.x) * step(abs(local.y), half_size.y + 8.0); if (tick_area > 0.5) { - let ticks = array(0.0, 0.25, 0.5, 0.75, 0.875, 1.0); - for (var index = 0u; index < 6u; index = index + 1u) { + let ticks = array(0.0, 0.33, 0.66, 1.0); + for (var index = 0u; index < 4u; index = index + 1u) { let tick_x = (ticks[index] - 0.5) * track_width; let tick = step(abs(local.x - tick_x), 1.0) * step(abs(local.y), half_size.y + 7.0); color = max(color, vec3f(0.78, 0.84, 0.90) * tick); @@ -163,19 +238,21 @@ fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f { @vertex fn vs_glyph(vertex: GlyphVertexInput, instance: GlyphInstanceInput) -> GlyphVertexOutput { let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0); - let pixel_size = u.glyph.x * mix(0.92, 1.10, saturate(instance.style.x)); + let shaped = pow(saturate(instance.style.x), 0.9); + let pixel_size = u.glyph.x * mix(1.08, 2.20, shaped); let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0; var out: GlyphVertexOutput; out.clip_position = vec4f(center.xy + ndc_offset * center.w, center.z, center.w); out.local = vertex.local; out.intensity = instance.style.x; + out.display_value = instance.style.y; return out; } @fragment fn fs_glyph(in: GlyphVertexOutput) -> @location(0) vec4f { - let alpha = digit_zero_alpha(in.local); - let color = sample_range_color(in.intensity) * 1.08; + let alpha = number_alpha(in.local, in.display_value); + let color = sample_range_color(in.intensity) * mix(0.82, 1.16, saturate(in.intensity)); return vec4f(color, alpha); } diff --git a/src/ui.rs b/src/ui.rs index e2f8852..cb4219d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,6 +10,9 @@ pub struct FloatingPanelState { pub visible: bool, default_pos: egui::Pos2, tag_pos: egui::Pos2, + center_anim: f32, + center_anim_target: bool, + center_anim_last_time: Option, } pub struct ConfigPanelState { @@ -63,6 +66,9 @@ impl FloatingPanelState { visible: true, default_pos: egui::pos2(default_pos[0], default_pos[1]), tag_pos: egui::pos2(tag_pos[0], tag_pos[1]), + center_anim: 1.0, + center_anim_target: true, + center_anim_last_time: None, } } } @@ -131,7 +137,7 @@ pub fn draw_connect_panel( ConnectionState::Connected | ConnectionState::Streaming ); - draw_center_floating_panel(ctx, panel, "connect_center_panel", 64.0, |ui| { + draw_center_floating_panel(ctx, panel, "connect_center_panel", 42.0, "连接", |ui| { ui.set_min_width(320.0); ui.vertical(|ui| { @@ -654,18 +660,308 @@ fn draw_center_floating_panel( panel: &mut FloatingPanelState, id: &'static str, top_offset: f32, + collapsed_label: &'static str, add_contents: impl FnOnce(&mut egui::Ui), ) { - if !panel.visible { + const PANEL_WIDTH: f32 = 430.0; + const SLIDE_DISTANCE: f32 = 18.0; + + let anim = advance_center_panel_anim(ctx, panel); + let eased = ease_in_out(anim); + + if !panel.visible || anim < 0.28 { + let handle_y = top_offset - (1.0 - eased) * 2.0; + egui::Area::new(egui::Id::new(format!("{id}_handle"))) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, handle_y)) + .order(egui::Order::Tooltip) + .show(ctx, |ui| { + let handle_rect = ui + .allocate_exact_size(egui::vec2(112.0, 24.0), egui::Sense::hover()) + .0; + if integrated_handle(ui, handle_rect, collapsed_label, false, "打开连接面板") + .clicked() + { + panel.visible = true; + } + }); + } + + if anim <= 0.02 { return; } egui::Area::new(egui::Id::new(id)) - .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, top_offset)) - .order(egui::Order::Foreground) + .anchor( + egui::Align2::CENTER_TOP, + egui::vec2(0.0, top_offset - (1.0 - eased) * SLIDE_DISTANCE), + ) + .order(egui::Order::Tooltip) .show(ctx, |ui| { - panel_frame(ctx).show(ui, |ui| { + let response = center_panel_shell(ui, PANEL_WIDTH, |ui| { + ui.set_width(PANEL_WIDTH); add_contents(ui); + ui.add_space(6.0); + let handle_rect = allocate_center_panel_handle(ui); + if integrated_handle(ui, handle_rect, "", true, "收起连接面板").clicked() { + panel.visible = false; + } + handle_rect }); + paint_integrated_center_panel(ui, response.response.rect, response.inner, true); }); } + +fn advance_center_panel_anim(ctx: &egui::Context, panel: &mut FloatingPanelState) -> f32 { + let target = if panel.visible { 1.0 } else { 0.0 }; + let now = ctx.input(|input| input.time); + let delta_time = panel + .center_anim_last_time + .map(|last_time| (now - last_time) as f32) + .unwrap_or(1.0 / 60.0) + .clamp(0.0, 0.05); + + panel.center_anim_last_time = Some(now); + panel.center_anim_target = panel.visible; + + let speed = 7.5; + let step = speed * delta_time; + if panel.center_anim < target { + panel.center_anim = (panel.center_anim + step).min(target); + } else if panel.center_anim > target { + panel.center_anim = (panel.center_anim - step).max(target); + } + + if (panel.center_anim - target).abs() > 0.001 { + ctx.request_repaint(); + } + + panel.center_anim +} + +fn ease_in_out(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +fn center_panel_shell( + ui: &mut egui::Ui, + width: f32, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse { + egui::Frame::new() + .fill(ENGINEERING_DARK.panel) + .corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.set_width(width); + add_contents(ui) + }) +} + +fn allocate_center_panel_handle(ui: &mut egui::Ui) -> egui::Rect { + let handle_width = 112.0; + let handle_height = 24.0; + let left_space = ((ui.available_width() - handle_width) * 0.5).max(0.0); + let mut handle_rect = egui::Rect::NOTHING; + + ui.horizontal(|ui| { + ui.add_space(left_space); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(handle_width, handle_height), + egui::Sense::hover(), + ); + handle_rect = rect; + }); + + handle_rect +} + +fn integrated_handle( + ui: &mut egui::Ui, + rect: egui::Rect, + label: &str, + expanded: bool, + tooltip: &'static str, +) -> egui::Response { + let response = ui + .interact( + rect.expand2(egui::vec2(12.0, 5.0)), + ui.id().with(("center_panel_handle", expanded)), + egui::Sense::click(), + ) + .on_hover_text(tooltip); + + paint_integrated_handle(ui, rect, label, expanded, response.hovered()); + response +} + +fn paint_integrated_handle( + ui: &mut egui::Ui, + rect: egui::Rect, + label: &str, + expanded: bool, + hovered: bool, +) { + let center = rect.center(); + if !expanded { + let fill = if hovered { + ENGINEERING_DARK.panel_strong + } else { + egui::Color32::from_rgb(31, 41, 52) + }; + let points = vec![ + egui::pos2(rect.left() - 12.0, rect.top() - 1.0), + egui::pos2(rect.right() + 12.0, rect.top() - 1.0), + egui::pos2(rect.right() + 3.0, rect.bottom() + 8.0), + egui::pos2(rect.left() - 3.0, rect.bottom() + 8.0), + ]; + ui.painter().add(egui::Shape::convex_polygon( + points.clone(), + fill, + egui::Stroke::new(1.0, ENGINEERING_DARK.border), + )); + ui.painter().line_segment( + [points[2], points[3]], + egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft), + ); + } + + let arrow = if expanded { -1.0 } else { 1.0 }; + let arrow_center_x = if label.is_empty() { + center.x + } else { + rect.right() - 18.0 + }; + let arrow_center = egui::pos2(arrow_center_x, center.y); + let arrow_color = if hovered { + ENGINEERING_DARK.accent_hot + } else { + ENGINEERING_DARK.text_dim + }; + ui.painter().line_segment( + [ + arrow_center + egui::vec2(-6.0, -2.0 * arrow), + arrow_center + egui::vec2(0.0, 4.0 * arrow), + ], + egui::Stroke::new(1.7, arrow_color), + ); + ui.painter().line_segment( + [ + arrow_center + egui::vec2(6.0, -2.0 * arrow), + arrow_center + egui::vec2(0.0, 4.0 * arrow), + ], + egui::Stroke::new(1.7, arrow_color), + ); + + if !label.is_empty() { + let galley = ui.fonts_mut(|fonts| { + fonts.layout_no_wrap( + label.to_owned(), + egui::FontId::proportional(12.0), + ENGINEERING_DARK.text, + ) + }); + ui.painter().galley( + egui::pos2(rect.left() + 14.0, center.y - galley.size().y * 0.5 - 1.0), + galley, + ENGINEERING_DARK.text, + ); + } +} + +fn paint_integrated_center_panel( + ui: &egui::Ui, + rect: egui::Rect, + handle_rect: egui::Rect, + handle_on_bottom: bool, +) { + let painter = ui.painter(); + let stroke = egui::Stroke::new(1.0, ENGINEERING_DARK.border); + let accent_stroke = egui::Stroke::new(1.2, ENGINEERING_DARK.border_soft); + let top = rect.top(); + let left = rect.left(); + let right = rect.right(); + let bottom = rect.bottom(); + let radius = ENGINEERING_DARK.radius as f32; + let tab_left = handle_rect.left() - 18.0; + let tab_right = handle_rect.right() + 18.0; + let tab_recess = if handle_on_bottom { + handle_rect.top() - 2.0 + } else { + handle_rect.bottom() + 2.0 + }; + + if handle_on_bottom { + painter.line_segment( + [ + egui::pos2(left + radius, top), + egui::pos2(right - radius, top), + ], + stroke, + ); + } else { + painter.line_segment( + [egui::pos2(left + radius, top), egui::pos2(tab_left, top)], + stroke, + ); + painter.line_segment( + [egui::pos2(tab_right, top), egui::pos2(right - radius, top)], + stroke, + ); + } + painter.line_segment( + [ + egui::pos2(left, top + radius), + egui::pos2(left, bottom - radius), + ], + stroke, + ); + painter.line_segment( + [ + egui::pos2(right, top + radius), + egui::pos2(right, bottom - radius), + ], + stroke, + ); + if handle_on_bottom { + painter.line_segment( + [ + egui::pos2(left + radius, bottom), + egui::pos2(tab_left, bottom), + ], + stroke, + ); + painter.line_segment( + [ + egui::pos2(tab_right, bottom), + egui::pos2(right - radius, bottom), + ], + stroke, + ); + } else { + painter.line_segment( + [ + egui::pos2(left + radius, bottom), + egui::pos2(right - radius, bottom), + ], + stroke, + ); + } + + let tab_points = if handle_on_bottom { + vec![ + egui::pos2(tab_left, bottom), + egui::pos2(handle_rect.left() - 8.0, tab_recess), + egui::pos2(handle_rect.right() + 8.0, tab_recess), + egui::pos2(tab_right, bottom), + ] + } else { + vec![ + egui::pos2(tab_left, top), + egui::pos2(handle_rect.left() - 8.0, tab_recess), + egui::pos2(handle_rect.right() + 8.0, tab_recess), + egui::pos2(tab_right, top), + ] + }; + painter.add(egui::Shape::line(tab_points, accent_stroke)); +}