diff --git a/Cargo.lock b/Cargo.lock index a8e3dc1..0cdee20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1320,6 +1320,7 @@ dependencies = [ "log", "serialport", "tobj", + "winresource", ] [[package]] @@ -3645,6 +3646,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serialport" version = "4.9.0" @@ -3989,6 +3999,21 @@ dependencies = [ "ahash", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4019,6 +4044,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tracing" version = "0.1.44" @@ -5017,6 +5048,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 34b2ff2..9f9ee7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,10 +24,11 @@ log = "0.4.29" tobj = "4.0.4" gltf = "1.4.1" + [build-dependencies] anyhow = "1.0.102" fs_extra = "1.3.0" - +winresource = "0.1.31" [[bin]] name = "ESkinPlayer" diff --git a/build.rs b/build.rs index d16198d..f3a1433 100644 --- a/build.rs +++ b/build.rs @@ -27,6 +27,14 @@ fn main() -> Result<()> { copy_items(&items, &resource_dir, ©_options)?; } + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + let mut resource = winresource::WindowsResource::new(); + + resource.set_icon("res/icon.ico"); + + resource.compile().expect("compile windows icon failed"); + } + println!("cargo:rustc-env=RESOURCE_DIR={}", resource_dir.display()); Ok(()) diff --git a/res/128x128@2x.png b/res/128x128@2x.png new file mode 100644 index 0000000..257a22b Binary files /dev/null and b/res/128x128@2x.png differ diff --git a/res/icon.ico b/res/icon.ico new file mode 100644 index 0000000..74ee25d Binary files /dev/null and b/res/icon.ico differ diff --git a/src/app.rs b/src/app.rs index 801bd56..b13b484 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crate::breakout::{BreakoutGame, control_from_matrix}; -use crate::connection::ConnectionManager; -use crate::force::{ForceEstimatorState, HudSpatialForce}; +use crate::connection::{ConnectionManager, ConnectionState}; +use crate::force::{ForceEstimatorState, HAND_FINGERTIP_COUNT, HudSpatialForce}; use crate::recording::Recorder; use crate::render::{ActiveMode, FingerMode, HandGatewayMode}; use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout}; @@ -25,7 +25,16 @@ use std::{ const SUMMARY_POINTS_PER_SERIES: usize = 42; const HAND_FORCE_PANEL_COUNT: usize = 7; const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44]; +const HAND_IMAGE_SIZE_PX: [f32; 2] = [971.0, 1117.0]; +const HAND_FINGERTIP_FORCE_ANCHORS_PX: [[f32; 2]; HAND_FINGERTIP_COUNT] = [ + [265.0, 495.0], + [359.0, 260.0], + [482.0, 228.0], + [596.0, 265.0], + [693.0, 370.0], +]; const RATE_UPDATE_INTERVAL: Duration = Duration::from_millis(500); +const DATA_IDLE_PANEL_EXIT_DELAY: Duration = Duration::from_secs(3); pub struct EskinDesktopApp { connect_panel: FloatingPanelState, @@ -47,10 +56,12 @@ pub struct EskinDesktopApp { hand_signal_histories: [Vec; HAND_FORCE_PANEL_COUNT], force_estimator: ForceEstimatorState, latest_spatial_force: Option, + latest_hand_spatial_forces: [Option; HAND_FINGERTIP_COUNT], spatial_force_panel_force: Option, latest_raw_matrix: Vec, latest_matrix_rows: u32, latest_matrix_cols: u32, + last_sample_at: Option, breakout_visible: bool, breakout_game: BreakoutGame, live_rates: LiveRateMeters, @@ -158,10 +169,12 @@ impl EskinDesktopApp { hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)), force_estimator: ForceEstimatorState::new(), latest_spatial_force: None, + latest_hand_spatial_forces: [None; HAND_FINGERTIP_COUNT], spatial_force_panel_force: None, latest_raw_matrix: Vec::new(), latest_matrix_rows: MATRIX_ROWS, latest_matrix_cols: MATRIX_COLS, + last_sample_at: None, breakout_visible: false, breakout_game: BreakoutGame::default(), live_rates: LiveRateMeters::new(), @@ -185,35 +198,85 @@ impl EskinDesktopApp { active_mode: self.active_mode.clone(), }, )); + self.paint_spatial_force_overlay(ui, rect); } fn paint_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) { + if !self.recent_sample_is_fresh() { + return; + } + + if matches!(self.active_mode, ActiveMode::Hand(_)) { + self.paint_hand_spatial_force_overlay(ui, rect); + return; + } + let Some(force) = self.latest_spatial_force else { return; }; - let painter = ui.painter(); let center = rect.center(); - let magnitude = force.magnitude.clamp(0.0, 1.8); - let length = 34.0 + magnitude * 54.0; - let angle = force.angle_deg.to_radians(); - let direction = egui::vec2(angle.cos(), angle.sin()); - let end = center + direction * length; - let side = egui::vec2(-direction.y, direction.x); - let color = egui::Color32::from_rgb(255, 196, 54); - let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58); + paint_spatial_force_arrow(ui.painter(), center, force, 34.0, 54.0); + } - painter.line_segment([center, end], egui::Stroke::new(9.0, glow)); - painter.line_segment([center, end], egui::Stroke::new(2.4, color)); - painter.line_segment( - [end, end - direction * 14.0 + side * 7.0], - egui::Stroke::new(2.4, color), - ); - painter.line_segment( - [end, end - direction * 14.0 - side * 7.0], - egui::Stroke::new(2.4, color), - ); - painter.circle_filled(center, 4.2, color); + fn paint_hand_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) { + for (force, anchor_px) in self + .latest_hand_spatial_forces + .iter() + .zip(HAND_FINGERTIP_FORCE_ANCHORS_PX) + { + let Some(force) = force else { + continue; + }; + + let center = hand_image_pixel_to_screen(rect, anchor_px); + paint_spatial_force_arrow(ui.painter(), center, *force, 20.0, 34.0); + } + } + + fn strongest_hand_spatial_force(&self) -> Option { + self.latest_hand_spatial_forces + .iter() + .flatten() + .copied() + .max_by(|a, b| a.magnitude.total_cmp(&b.magnitude)) + } + + fn analyze_spatial_force(&mut self, values: &[u32]) { + if matches!(self.config_state.mode, SerialMode::Hand) { + self.latest_hand_spatial_forces = self.force_estimator.analyze_fingertips(values); + self.latest_spatial_force = self.strongest_hand_spatial_force(); + } else { + self.latest_spatial_force = self.force_estimator.analyze(values); + self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT]; + } + } + + fn connection_has_live_data(&self) -> bool { + matches!( + self.connection.state(), + ConnectionState::Connected | ConnectionState::Streaming + ) + } + + fn recent_sample_is_fresh(&self) -> bool { + self.last_sample_at + .is_some_and(|sample_at| sample_at.elapsed() <= DATA_IDLE_PANEL_EXIT_DELAY) + } + + fn hand_force_panels_visible(&self) -> bool { + self.connection_has_live_data() && self.recent_sample_is_fresh() + } + + fn sync_disconnected_live_state(&mut self) { + if self.connection_has_live_data() { + return; + } + + self.last_sample_at = None; + self.latest_spatial_force = None; + self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT]; + self.spatial_force_panel_force = None; } fn draw_workspace(&mut self, ui: &mut egui::Ui) { @@ -264,6 +327,7 @@ impl EskinDesktopApp { fn update_pressure_matrix(&mut self) { if let Some(sample) = self.connection.take_latest_sample() { + self.last_sample_at = Some(Instant::now()); normalize_pressure_sample( &sample.matrix, sample.rows, @@ -276,7 +340,7 @@ impl EskinDesktopApp { self.latest_matrix_rows = sample.rows; self.latest_matrix_cols = sample.cols; - self.latest_spatial_force = self.force_estimator.analyze(&sample.matrix); + self.analyze_spatial_force(&sample.matrix); // Keep JE-Skin's summary path separate from the optional spatial-force vector. let raw_total = sample @@ -431,7 +495,11 @@ impl EskinDesktopApp { } self.stats_panel.visible = false; - draw_hand_force_panels(ctx, true, &self.hand_signal_histories); + draw_hand_force_panels( + ctx, + self.hand_force_panels_visible(), + &self.hand_signal_histories, + ); } fn draw_panel_context_menu(&mut self, ctx: &egui::Context) { @@ -518,8 +586,10 @@ impl EskinDesktopApp { self.connection.disconnect(); self.pressure_matrix.fill([0.0, 0.0]); self.hand_pressure.clear(); + self.last_sample_at = None; self.force_estimator.reset(); self.latest_spatial_force = None; + self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT]; self.spatial_force_panel_force = None; self.signal_history.clear(); self.hand_signal_histories @@ -538,6 +608,60 @@ impl EskinDesktopApp { } } +fn hand_image_pixel_to_screen(rect: egui::Rect, point_px: [f32; 2]) -> egui::Pos2 { + let image_uv = egui::vec2( + point_px[0] / HAND_IMAGE_SIZE_PX[0].max(1.0), + point_px[1] / HAND_IMAGE_SIZE_PX[1].max(1.0), + ); + let viewport_aspect = rect.width() / rect.height().max(1.0); + let image_aspect = HAND_IMAGE_SIZE_PX[0] / HAND_IMAGE_SIZE_PX[1].max(1.0); + let mut screen_uv = image_uv; + + if viewport_aspect > image_aspect { + let image_width = image_aspect / viewport_aspect; + screen_uv.x = image_uv.x * image_width + (1.0 - image_width) * 0.5; + } else { + let image_height = viewport_aspect / image_aspect; + screen_uv.y = image_uv.y * image_height + (1.0 - image_height) * 0.5; + } + + egui::pos2( + rect.left() + screen_uv.x * rect.width(), + rect.top() + screen_uv.y * rect.height(), + ) +} + +fn paint_spatial_force_arrow( + painter: &egui::Painter, + center: egui::Pos2, + force: HudSpatialForce, + base_length: f32, + magnitude_scale: f32, +) { + let magnitude = force.magnitude.clamp(0.0, 1.8); + let length = base_length + magnitude * magnitude_scale; + let angle = force.angle_deg.to_radians(); + let direction = egui::vec2(angle.cos(), angle.sin()); + let end = center + direction * length; + let side = egui::vec2(-direction.y, direction.x); + let head_length = (length * 0.24).clamp(8.0, 14.0); + let head_width = head_length * 0.5; + let color = egui::Color32::from_rgb(255, 196, 54); + let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58); + + painter.line_segment([center, end], egui::Stroke::new(8.0, glow)); + painter.line_segment([center, end], egui::Stroke::new(2.4, color)); + painter.line_segment( + [end, end - direction * head_length + side * head_width], + egui::Stroke::new(2.4, color), + ); + painter.line_segment( + [end, end - direction * head_length - side * head_width], + egui::Stroke::new(2.4, color), + ); + painter.circle_filled(center, 3.8, color); +} + fn update_hand_signal_histories( histories: &mut [Vec; HAND_FORCE_PANEL_COUNT], raw_values: &[u32], @@ -718,6 +842,7 @@ impl eframe::App for EskinDesktopApp { self.live_rates.tick_render(stats.rx_frames); self.update_pressure_matrix(); + self.sync_disconnected_live_state(); self.draw_workspace(ui); self.draw_title_bar(ui, frame); self.draw_floating_panels(&ctx, stats); diff --git a/src/force.rs b/src/force.rs index 24b258b..4a7288e 100644 --- a/src/force.rs +++ b/src/force.rs @@ -1,6 +1,7 @@ use crate::serial_core::multi_dim_force::PztProcessor; -const FINGER_SAMPLE_COUNT: usize = 84; +pub const FINGER_SAMPLE_COUNT: usize = 84; +pub const HAND_FINGERTIP_COUNT: usize = 5; const MIN_TANGENTIAL_MAGNITUDE: f32 = 0.02; #[derive(Debug, Clone, Copy)] @@ -11,17 +12,22 @@ pub struct HudSpatialForce { pub struct ForceEstimatorState { pzt_processor: PztProcessor, + fingertip_processors: [PztProcessor; HAND_FINGERTIP_COUNT], } impl ForceEstimatorState { pub fn new() -> Self { Self { pzt_processor: PztProcessor::new(), + fingertip_processors: std::array::from_fn(|_| PztProcessor::new()), } } pub fn reset(&mut self) { self.pzt_processor.reset_baseline(); + self.fingertip_processors + .iter_mut() + .for_each(PztProcessor::reset_baseline); } pub fn analyze(&mut self, values: &[u32]) -> Option { @@ -40,6 +46,37 @@ impl ForceEstimatorState { magnitude: analysis.magnitude, }) } + + pub fn analyze_fingertips( + &mut self, + values: &[u32], + ) -> [Option; HAND_FINGERTIP_COUNT] { + let mut forces = [None; HAND_FINGERTIP_COUNT]; + + for (tip_index, processor) in self.fingertip_processors.iter_mut().enumerate() { + let start = tip_index * FINGER_SAMPLE_COUNT; + let end = start + FINGER_SAMPLE_COUNT; + let Some(segment) = values.get(start..end) else { + break; + }; + + let pzt_values = segment + .iter() + .map(|value| *value as f32) + .collect::>(); + + forces[tip_index] = processor + .get_pzt_analysis(&pzt_values) + .ok() + .filter(|analysis| analysis.magnitude > MIN_TANGENTIAL_MAGNITUDE) + .map(|analysis| HudSpatialForce { + angle_deg: analysis.angle_deg, + magnitude: analysis.magnitude, + }); + } + + forces + } } impl Default for ForceEstimatorState { diff --git a/src/main.rs b/src/main.rs index 96ac35d..c812a2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,12 +20,15 @@ use eframe::egui; fn main() -> eframe::Result<()> { env_logger::init(); + let window_icon = eframe::icon_data::from_png_bytes(include_bytes!("../res/128x128@2x.png")) + .expect("load window icon failed"); let options = eframe::NativeOptions { renderer: eframe::Renderer::Wgpu, viewport: egui::ViewportBuilder::default() .with_inner_size([1920.0, 1080.0]) .with_min_inner_size([1280.0, 720.0]) - .with_decorations(false), + .with_decorations(false) + .with_icon(window_icon), ..Default::default() }; diff --git a/src/render.rs b/src/render.rs index 022724c..cd6cdaf 100644 --- a/src/render.rs +++ b/src/render.rs @@ -73,14 +73,14 @@ const HAND_TIP_MATRICES: [HandTipMatrix; 5] = [ const HAND_PALM_CHIPS: [HandPalmChip; 2] = [ HandPalmChip { center_px: [530.0, 593.0], - size_px: [224.0, 68.0], + size_px: [258.0, 88.0], angle_rad: 0.12, rows: 5, cols: 14, }, HandPalmChip { center_px: [610.0, 778.0], - size_px: [60.0, 190.0], + size_px: [82.0, 228.0], angle_rad: 0.05, rows: 11, cols: 4, @@ -1203,8 +1203,7 @@ fn build_hand_dot_instances( // Lay out a rows x cols matrix in fingertip-local pixel space. let local_x = (col as f32 - cols as f32 / 2.0 + 0.5) / cols as f32 * tip.size_px[0]; - let local_y = (row as f32 - rows as f32 / 2.0 + 0.5) / rows as f32 - * tip.size_px[1] + let local_y = (row as f32 - rows as f32 / 2.0 + 0.5) / rows as f32 * tip.size_px[1] + HAND_TIP_DOT_LOCAL_Y_OFFSET_PX; // Rotate the local matrix so it follows the direction of the finger. @@ -1241,8 +1240,8 @@ fn build_hand_palm_dot_instances( for (chip_index, chip) in HAND_PALM_CHIPS.into_iter().enumerate() { let cos = chip.angle_rad.cos(); let sin = chip.angle_rad.sin(); - // Leave a bevel around the chip so the matrix reads as embedded pixels. - let active_size = [chip.size_px[0] * 0.72, chip.size_px[1] * 0.76]; + // Palm films can float slightly beyond the hand artwork, similar to fingertip membranes. + let active_size = [chip.size_px[0] * 0.86, chip.size_px[1] * 0.86]; for row in 0..chip.rows { for col in 0..chip.cols { @@ -1278,13 +1277,7 @@ fn build_hand_palm_dot_instances( instances } -fn hand_palm_pressure_index( - chip_index: usize, - row: u32, - col: u32, - rows: u32, - cols: u32, -) -> usize { +fn hand_palm_pressure_index(chip_index: usize, row: u32, col: u32, rows: u32, cols: u32) -> usize { if chip_index == 0 { (col * rows + (rows - 1 - row)) as usize } else { diff --git a/src/ui.rs b/src/ui.rs index 0377b65..a1de890 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -413,8 +413,11 @@ pub fn draw_config_panel( .order(egui::Order::Foreground) .show(ctx, |ui| { ui.set_min_size(bar_rect.size()); - ui.painter() - .rect_filled(ui.max_rect(), egui::CornerRadius::ZERO, ONE_DARK_PRO.panel_deep); + ui.painter().rect_filled( + ui.max_rect(), + egui::CornerRadius::ZERO, + ONE_DARK_PRO.panel_deep, + ); ui.painter().line_segment( [ui.max_rect().left_top(), ui.max_rect().right_top()], egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft), @@ -426,93 +429,93 @@ pub fn draw_config_panel( ui.add_space(5.0); ui.horizontal(|ui| { - ui.add_space(8.0); - ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); - config.mode = SerialMode::Hand; - config.baud_rate = SerialMode::Hand.baud_rate(); + ui.add_space(8.0); + ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); + config.mode = SerialMode::Hand; + config.baud_rate = SerialMode::Hand.baud_rate(); - ui.colored_label(dim_text(), "配置面板"); - ui.add_space(8.0); - ui.colored_label(dim_text(), "模式"); - ui.add_sized( - egui::vec2(102.0, 28.0), - style::mode_button("手掌模块", true), - ); + ui.colored_label(dim_text(), "配置面板"); + ui.add_space(8.0); + ui.colored_label(dim_text(), "模式"); + ui.add_sized( + egui::vec2(102.0, 28.0), + style::mode_button("手掌模块", true), + ); - ui.add_space(12.0); - ui.label(style::field_label("端口")); - egui::ComboBox::from_id_salt("config_top_bar_ports") - .width(180.0) - .selected_text(if config.port.is_empty() { - "无可用串口".to_owned() - } else { - config.port.clone() - }) - .show_ui(ui, |ui| { - for port in &config.available_ports { - ui.selectable_value(&mut config.port, port.clone(), port.as_str()); - } - }); - - if ui - .add(style::icon_button( - egui::RichText::new("⟳").color(ONE_DARK_PRO.text).size(16.0), - METRICS.icon_button, - )) - .on_hover_text("刷新串口") - .clicked() - { - refresh_config_ports(config); - } - - ui.add_space(10.0); - ui.label(style::value_text(format!("波特率 {}", config.baud_rate))); - ui.add_space(10.0); - ui.colored_label(dim_text(), "状态"); - ui.colored_label( - connection_status_color(conn_state), - connection_status_text(conn_state), - ); - - ui.add_space(8.0); - let is_connected = matches!( - conn_state, - ConnectionState::Connected | ConnectionState::Streaming - ); - let button_text = if is_connected { "断开" } else { "连接" }; - if ui - .add_sized( - egui::vec2(118.0, 28.0), - if is_connected { - style::danger_button(button_text) - } else { - style::primary_button(button_text) - }, - ) - .clicked() - { - if is_connected { - connection.disconnect(); - } else if !config.port.is_empty() { - connection.connect( - &config.port, - 12, - 7, - config.mode.baud_rate(), - config.mode.protocol(), - recorder.clone(), - ); + ui.add_space(12.0); + ui.label(style::field_label("端口")); + egui::ComboBox::from_id_salt("config_top_bar_ports") + .width(180.0) + .selected_text(if config.port.is_empty() { + "无可用串口".to_owned() + } else { + config.port.clone() + }) + .show_ui(ui, |ui| { + for port in &config.available_ports { + ui.selectable_value(&mut config.port, port.clone(), port.as_str()); } - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(style::value_text(format!("{render_rate_hz:.1} Hz"))); - ui.colored_label(dim_text(), "画面刷新率"); - ui.add_space(18.0); - ui.label(style::value_text(format!("{sample_rate_hz:.1} Hz"))); - ui.colored_label(dim_text(), "采样率"); }); + + if ui + .add(style::icon_button( + egui::RichText::new("⟳").color(ONE_DARK_PRO.text).size(16.0), + METRICS.icon_button, + )) + .on_hover_text("刷新串口") + .clicked() + { + refresh_config_ports(config); + } + + ui.add_space(10.0); + ui.label(style::value_text(format!("波特率 {}", config.baud_rate))); + ui.add_space(10.0); + ui.colored_label(dim_text(), "状态"); + ui.colored_label( + connection_status_color(conn_state), + connection_status_text(conn_state), + ); + + ui.add_space(8.0); + let is_connected = matches!( + conn_state, + ConnectionState::Connected | ConnectionState::Streaming + ); + let button_text = if is_connected { "断开" } else { "连接" }; + if ui + .add_sized( + egui::vec2(118.0, 28.0), + if is_connected { + style::danger_button(button_text) + } else { + style::primary_button(button_text) + }, + ) + .clicked() + { + if is_connected { + connection.disconnect(); + } else if !config.port.is_empty() { + connection.connect( + &config.port, + 12, + 7, + config.mode.baud_rate(), + config.mode.protocol(), + recorder.clone(), + ); + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(style::value_text(format!("{render_rate_hz:.1} Hz"))); + ui.colored_label(dim_text(), "画面刷新率"); + ui.add_space(18.0); + ui.label(style::value_text(format!("{sample_rate_hz:.1} Hz"))); + ui.colored_label(dim_text(), "采样率"); }); + }); }); changed_mode @@ -954,14 +957,17 @@ pub fn draw_stats_panel( const FORCE_CHART_MAX_N: f32 = 25.6; const FORCE_CHART_SAMPLE_SECONDS: f32 = 0.1; const FORCE_PANEL_ACTIVE_TAIL: usize = 8; +const FORCE_PANEL_EXIT_DELAY_SECONDS: f64 = 3.0; -const HAND_FORCE_PANEL_MIN_WIDTH: f32 = 220.0; -const HAND_FORCE_PANEL_MAX_WIDTH: f32 = 500.0; -const HAND_FORCE_PANEL_MIN_HEIGHT: f32 = 104.0; -const HAND_FORCE_PANEL_MAX_HEIGHT: f32 = 190.0; -const HAND_FORCE_PANEL_GAP: f32 = 12.0; +const HAND_FORCE_PANEL_MIN_WIDTH: f32 = 160.0; +const HAND_FORCE_PANEL_MAX_WIDTH: f32 = 560.0; +const HAND_FORCE_PANEL_MIN_HEIGHT: f32 = 72.0; +const HAND_FORCE_PANEL_MAX_HEIGHT: f32 = 230.0; +const HAND_FORCE_PANEL_GAP: f32 = 20.0; const HAND_FORCE_PANEL_SIDE_MARGIN: f32 = 24.0; const HAND_FORCE_PANEL_VERTICAL_MARGIN: f32 = 28.0; +const HAND_FORCE_CENTER_MIN_WIDTH: f32 = 420.0; +const HAND_FORCE_CENTER_MAX_WIDTH: f32 = 820.0; const HAND_FORCE_PANEL_TITLES: [(&str, &str); 7] = [ ("T1", "拇指"), ("T2", "食指"), @@ -981,15 +987,14 @@ fn has_recent_resultant_force(values: &[f32]) -> bool { } pub fn draw_hand_force_panels(ctx: &egui::Context, visible: bool, histories: &[Vec]) { - let hand_active = histories - .iter() - .any(|history| has_recent_resultant_force(history)); - let target_visible = visible && hand_active; let screen = ctx.content_rect(); let side_margin = HAND_FORCE_PANEL_SIDE_MARGIN.min((screen.width() * 0.025).max(10.0)); let vertical_margin = HAND_FORCE_PANEL_VERTICAL_MARGIN.min((screen.height() * 0.04).max(10.0)); - let gap = HAND_FORCE_PANEL_GAP.min((screen.height() * 0.018).max(6.0)); - let side_width = ((screen.width() - side_margin * 4.0) * 0.25).max(0.0); + let gap = HAND_FORCE_PANEL_GAP.min((screen.height() * 0.024).max(12.0)); + let reserved_center_width = (screen.width() * 0.42) + .clamp(HAND_FORCE_CENTER_MIN_WIDTH, HAND_FORCE_CENTER_MAX_WIDTH) + .min((screen.width() - side_margin * 2.0).max(0.0)); + let side_width = ((screen.width() - reserved_center_width - side_margin * 2.0) * 0.5).max(0.0); let panel_width = side_width .min(HAND_FORCE_PANEL_MAX_WIDTH) .max(HAND_FORCE_PANEL_MIN_WIDTH.min(side_width)); @@ -997,17 +1002,16 @@ pub fn draw_hand_force_panels(ctx: &egui::Context, visible: bool, histories: &[V let right_x = screen.right() - side_margin - panel_width; let left_count = 4usize; let right_count = HAND_FORCE_PANEL_TITLES.len() - left_count; - let available_height = - (screen.height() - layout::TITLE_BAR_HEIGHT - vertical_margin * 2.0).max(0.0); + let chrome_height = layout::TITLE_BAR_HEIGHT + layout::CONFIG_BAR_HEIGHT; + let available_height = (screen.height() - chrome_height - vertical_margin * 2.0).max(0.0); let panel_height = ((available_height - (left_count - 1) as f32 * gap) / left_count as f32) - .clamp( - HAND_FORCE_PANEL_MIN_HEIGHT.min(available_height), - HAND_FORCE_PANEL_MAX_HEIGHT, - ); + .max(0.0) + .min(HAND_FORCE_PANEL_MAX_HEIGHT) + .max(HAND_FORCE_PANEL_MIN_HEIGHT.min(available_height / left_count as f32)); let left_height = left_count as f32 * panel_height + (left_count - 1) as f32 * gap; let right_height = right_count as f32 * panel_height + (right_count.saturating_sub(1)) as f32 * gap; - let min_top = screen.top() + layout::TITLE_BAR_HEIGHT + vertical_margin; + let min_top = screen.top() + chrome_height + vertical_margin; let left_top = (screen.center().y - left_height * 0.5).max(min_top); let right_top = (screen.center().y - right_height * 0.5).max(min_top); @@ -1029,7 +1033,8 @@ pub fn draw_hand_force_panels(ctx: &egui::Context, visible: bool, histories: &[V egui::Id::new(format!("hand_force_panel_{index}")), egui::pos2(target_x, target_y), egui::vec2(panel_width, panel_height), - target_visible, + index < left_count, + visible, code, title, history, @@ -1042,12 +1047,35 @@ fn draw_force_chart_area( id: egui::Id, target_pos: egui::Pos2, size: egui::Vec2, + from_left: bool, target_visible: bool, code: &'static str, title: &'static str, values: &[f32], ) { - let anim = ctx.animate_bool(id.with("enter"), target_visible); + let now = ctx.input(|input| input.time); + let active = target_visible && has_recent_resultant_force(values); + let state_id = id.with("active_state"); + let mut last_active_at = ctx.data_mut(|data| data.get_temp::(state_id)); + if active { + last_active_at = Some(now); + ctx.data_mut(|data| data.insert_temp(state_id, now)); + } else if !target_visible { + last_active_at = None; + ctx.data_mut(|data| data.remove::(state_id)); + } + + let target_visible = + last_active_at.is_some_and(|active_at| now - active_at <= FORCE_PANEL_EXIT_DELAY_SECONDS); + let anim_id = id.with("enter"); + let anim_seeded_id = id.with("enter_seeded"); + let anim_seeded = ctx.data_mut(|data| data.get_temp::(anim_seeded_id).unwrap_or(false)); + if target_visible && !anim_seeded { + ctx.animate_bool(anim_id, false); + ctx.data_mut(|data| data.insert_temp(anim_seeded_id, true)); + } + + let anim = ctx.animate_bool(anim_id, target_visible); if anim <= 0.01 { return; @@ -1059,20 +1087,27 @@ fn draw_force_chart_area( } let eased = ease_in_out(anim); - let hidden_y = ctx.content_rect().bottom() + HAND_FORCE_PANEL_GAP; - let y = egui::lerp(hidden_y..=target_pos.y, eased); + let hidden_x = if from_left { + ctx.content_rect().left() - size.x - HAND_FORCE_PANEL_GAP + } else { + ctx.content_rect().right() + HAND_FORCE_PANEL_GAP + }; + let x = egui::lerp(hidden_x..=target_pos.x, eased); egui::Area::new(id) - .fixed_pos(egui::pos2(target_pos.x, y)) + .fixed_pos(egui::pos2(x, target_pos.y)) .constrain_to(viewport_panel_rect(ctx)) .order(egui::Order::Foreground) .show(ctx, |ui| { panel_frame(ctx).show(ui, |ui| { + let padding = METRICS.panel_padding as f32; + let inner_width = (size.x - padding * 2.0).max(80.0); + let inner_height = (size.y - padding * 2.0).max(48.0); ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); - ui.set_min_width(size.x); - ui.set_max_width(size.x); - ui.set_min_height(size.y); - draw_force_chart_panel_contents(ui, code, title, values, size.y); + ui.set_min_width(inner_width); + ui.set_max_width(inner_width); + ui.set_min_height(inner_height); + draw_force_chart_panel_contents(ui, code, title, values, inner_height); }); }); } @@ -1095,7 +1130,7 @@ fn draw_force_chart_panel_contents( }); ui.add_space(6.0); - let chart_height = (content_height - 32.0).max(90.0); + let chart_height = (content_height - 32.0).max(36.0); paint_resultant_force_chart(ui, values, chart_height); } @@ -1103,7 +1138,7 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height: const CHART_POINTS: usize = 42; const CHART_WINDOW_SECONDS: f32 = CHART_POINTS as f32 * FORCE_CHART_SAMPLE_SECONDS; - let width = ui.available_width().max(180.0); + let width = ui.available_width().max(80.0); let (rect, _) = ui.allocate_exact_size(egui::vec2(width, chart_height), egui::Sense::hover()); let painter = ui.painter_at(rect); let radius = egui::CornerRadius::same(5); @@ -1358,8 +1393,10 @@ fn draw_pinned_panel( let panel_width = responsive_panel_width(ctx, preferred_width, min_width); let max_height = responsive_panel_height(ctx, ctx.content_rect().height(), 180.0); let viewport = viewport_panel_rect(ctx); - let x = (viewport.center().x - panel_width * 0.5) - .clamp(viewport.left(), (viewport.right() - panel_width).max(viewport.left())); + let x = (viewport.center().x - panel_width * 0.5).clamp( + viewport.left(), + (viewport.right() - panel_width).max(viewport.left()), + ); let y = viewport.top() + layout::TITLE_BAR_HEIGHT + PANEL_VIEWPORT_MARGIN; let pos = egui::pos2(x, y); diff --git a/static/wgsl/shader.wgsl b/static/wgsl/shader.wgsl index d503185..c3e7745 100644 --- a/static/wgsl/shader.wgsl +++ b/static/wgsl/shader.wgsl @@ -478,8 +478,8 @@ fn fs_hand_membrane(in: HandMembraneVertexOutput) -> @location(0) vec4f { let bevel_highlight = vec3f(0.48, 1.00, 1.00); let bevel_dark_color = vec3f(0.004, 0.035, 0.060); let dot_color = vec3f(0.08, 0.48, 0.58); - let pressure_green = vec3f(0.01, 0.78, 0.23); - let pressure_hot_color = vec3f(0.20, 1.00, 0.42); + let pressure_color = dot_color; + let pressure_hot_color = dot_color; let color = extrusion_color * extrusion * 0.90 + extrusion_edge_color * extrusion * halo_shape * 0.32 @@ -492,7 +492,7 @@ fn fs_hand_membrane(in: HandMembraneVertexOutput) -> @location(0) vec4f { + bevel_highlight * shell_sheen * 0.28 + rim_color * halo * 0.16 + dot_color * dots * 0.68 - + pressure_green * dots * pressure * 0.82 + + pressure_color * dots * pressure * 0.82 + pressure_hot_color * dots * pressure_hot * 0.90; let alpha = clamp( @@ -583,13 +583,13 @@ fn vs_hand_palm_chip(vertex: DotVertexInput, instance: DotInstanceInput) -> Hand @fragment fn fs_hand_palm_chip(in: HandPalmChipVertexOutput) -> @location(0) vec4f { // Inactive palm matrix: only independent dots, with no filled patch underneath. - let panel = rounded_rect_alpha(in.local, 0.11, 0.040); - let inner = rounded_rect_alpha(in.local * vec2f(1.08, 1.08), 0.09, 0.052); + let panel = rounded_rect_alpha(in.local, 0.16, 0.050); + let inner = rounded_rect_alpha(in.local * vec2f(1.03, 1.03), 0.13, 0.062); let uv = clamp(in.local * 0.5 + vec2f(0.5, 0.5), vec2f(0.0, 0.0), vec2f(1.0, 1.0)); let patch_mask = smoothstep(0.0, 0.36, inner) * panel; - let grid = max(in.grid * 1.70, vec2f(1.0, 1.0)); + let grid = max(in.grid * 1.42, vec2f(1.0, 1.0)); let cell_uv = fract(uv * grid) - vec2f(0.5, 0.5); let dot_dist = length(cell_uv); let dot_aa = max(fwidth(dot_dist) * 1.5, 0.008); @@ -607,8 +607,8 @@ fn fs_hand_palm_chip(in: HandPalmChipVertexOutput) -> @location(0) vec4f { let pressure_hot = (1.0 - smoothstep(0.02, 0.14, pressure_dist)) * patch_mask; let idle_dot_color = vec3f(0.18, 0.32, 0.38); - let active_dot_color = vec3f(0.02, 0.88, 0.34); - let hot_dot_color = vec3f(0.28, 1.00, 0.52); + let active_dot_color = idle_dot_color; + let hot_dot_color = idle_dot_color; let dot_color = mix(idle_dot_color, active_dot_color, pressure); @@ -633,7 +633,7 @@ fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVe // Palm chip pixels are deliberately smaller than fingertip beads so they read as a chip matrix. let center = hand_image_uv_to_clip(instance.world_position.xy); - let pixel_size = u.glyph.x * mix(0.13, 0.25, shaped); + let pixel_size = u.glyph.x * mix(0.16, 0.30, shaped); let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0; var out: DotVertexOutput;