From 444c20f233a88d08aba074e2eceff5712671c203 Mon Sep 17 00:00:00 2001 From: lenn Date: Tue, 30 Jun 2026 11:10:29 +0800 Subject: [PATCH] Add animated force panels --- src/app.rs | 74 +++++++++++++-- src/ui.rs | 271 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 257 insertions(+), 88 deletions(-) diff --git a/src/app.rs b/src/app.rs index 40b0c5b..8f83f55 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,14 +13,16 @@ use crate::{ }, ui::{ ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState, - draw_config_panel, draw_export_panel, draw_matrix_config_panel, draw_stats_panel, - panel_restore_item, + draw_config_panel, draw_export_panel, draw_hand_force_panels, draw_matrix_config_panel, + draw_stats_panel, panel_restore_item, }, }; use eframe::{egui, egui_wgpu}; use std::sync::Arc; 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]; pub struct EskinDesktopApp { connect_panel: FloatingPanelState, @@ -39,6 +41,7 @@ pub struct EskinDesktopApp { matrix_config_panel: FloatingPanelState, matrix_config: MatrixConfigState, signal_history: Vec, + hand_signal_histories: [Vec; HAND_FORCE_PANEL_COUNT], force_estimator: ForceEstimatorState, latest_spatial_force: Option, latest_raw_matrix: Vec, @@ -104,6 +107,7 @@ impl EskinDesktopApp { ), matrix_config: MatrixConfigState::default(), signal_history: Vec::with_capacity(128), + hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)), force_estimator: ForceEstimatorState::new(), latest_spatial_force: None, latest_raw_matrix: Vec::new(), @@ -243,6 +247,10 @@ impl EskinDesktopApp { if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES { self.signal_history.remove(0); } + + if self.config_state.mode == SerialMode::Hand { + update_hand_signal_histories(&mut self.hand_signal_histories, &sample.matrix); + } } } @@ -367,12 +375,19 @@ impl EskinDesktopApp { ) { self.switch_mode(next_mode); } - draw_stats_panel( - ctx, - &mut self.stats_panel, - &self.signal_history, - self.latest_spatial_force, - ); + match self.config_state.mode { + SerialMode::Finger => { + draw_stats_panel( + ctx, + &mut self.stats_panel, + &self.signal_history, + self.latest_spatial_force, + ); + } + SerialMode::Hand => { + draw_hand_force_panels(ctx, self.stats_panel.visible, &self.hand_signal_histories); + } + } draw_export_panel( ctx, &mut self.export_panel, @@ -416,14 +431,26 @@ impl EskinDesktopApp { panel_restore_item(ui, "录制", &mut self.export_panel); panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel); panel_restore_item(ui, "统计", &mut self.stats_panel); - if ui.button("打砖块").clicked() { + if ui + .add_sized( + egui::vec2(ui.available_width(), 0.0), + egui::Button::new("打砖块"), + ) + .clicked() + { self.breakout_visible = true; close_menu = true; } ui.separator(); - if ui.button("全部显示").clicked() { + if ui + .add_sized( + egui::vec2(ui.available_width(), 0.0), + egui::Button::new("全部显示"), + ) + .clicked() + { self.config_panel.visible = true; self.export_panel.visible = true; self.matrix_config_panel.visible = true; @@ -456,6 +483,9 @@ impl EskinDesktopApp { self.force_estimator.reset(); self.latest_spatial_force = None; self.signal_history.clear(); + self.hand_signal_histories + .iter_mut() + .for_each(|history| history.clear()); self.active_mode = match next { SerialMode::Finger => ActiveMode::Finger(FingerMode { @@ -469,6 +499,30 @@ impl EskinDesktopApp { } } +fn update_hand_signal_histories( + histories: &mut [Vec; HAND_FORCE_PANEL_COUNT], + raw_values: &[u32], +) { + let mut offset = 0usize; + for (history, sample_count) in histories.iter_mut().zip(HAND_FORCE_SEGMENT_COUNTS) { + let raw_total = raw_values + .get(offset..offset + sample_count) + .unwrap_or(&[]) + .iter() + .fold(0_u64, |sum, value| sum + *value as u64) + .min(u32::MAX as u64) as u32; + let force = raw_to_g1(raw_total).min(25.6); + let force = if force <= 0.1 { 0.0 } else { force }; + + history.push(force); + if history.len() > SUMMARY_POINTS_PER_SERIES { + history.remove(0); + } + + offset += sample_count; + } +} + fn raw_to_g1(raw: u32) -> f32 { const RAW: [u32; 12] = [ 0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444, diff --git a/src/ui.rs b/src/ui.rs index e8d3b29..31e8e7e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -697,84 +697,181 @@ pub fn draw_stats_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, force_history: &[f32], - spatial_force: Option, + _spatial_force: Option, ) { - draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| { - ui.set_min_width(320.0); - draw_resultant_force_chart(ui, force_history, spatial_force); - }); + const PANEL_WIDTH: f32 = 420.0; + const PANEL_HEIGHT: f32 = 380.0; + const PANEL_OUTSIDE_GAP: f32 = 18.0; + + let force_active = has_recent_resultant_force(force_history); + let target_visible = panel.visible && force_active; + let anim = ctx.animate_bool(egui::Id::new("stats_panel_force_enter"), target_visible); + + if anim <= 0.01 { + return; + } + + let target_anim = if target_visible { 1.0 } else { 0.0 }; + if (anim - target_anim).abs() > 0.001 { + ctx.request_repaint(); + } + + let eased = ease_in_out(anim); + let screen = ctx.content_rect(); + let target_x = panel.default_pos.x; + let hidden_x = if target_x < screen.center().x { + screen.left() - PANEL_WIDTH - PANEL_OUTSIDE_GAP + } else { + screen.right() + PANEL_OUTSIDE_GAP + }; + let x = egui::lerp(hidden_x..=target_x, eased); + let y = panel.default_pos.y; + + egui::Area::new(egui::Id::new("stats_panel")) + .fixed_pos(egui::pos2(x, y)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + panel_frame(ctx).show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); + ui.set_min_width(PANEL_WIDTH); + ui.set_min_height(PANEL_HEIGHT); + draw_force_chart_panel_contents(ui, "RF", "合力", force_history, PANEL_HEIGHT); + }); + }); } const FORCE_CHART_MAX_N: f32 = 25.6; +const FORCE_CHART_SAMPLE_SECONDS: f32 = 0.1; +const FORCE_PANEL_ACTIVE_TAIL: usize = 8; -fn draw_resultant_force_chart( - ui: &mut egui::Ui, +const HAND_FORCE_PANEL_WIDTH: f32 = 260.0; +const HAND_FORCE_PANEL_HEIGHT: f32 = 150.0; +const HAND_FORCE_PANEL_GAP: f32 = 12.0; +const HAND_FORCE_PANEL_BOTTOM_MARGIN: f32 = 24.0; +const HAND_FORCE_PANEL_TITLES: [(&str, &str); 7] = [ + ("T1", "拇指"), + ("T2", "食指"), + ("T3", "中指"), + ("T4", "无名指"), + ("T5", "小指"), + ("P1", "掌心横区"), + ("P2", "掌心纵区"), +]; + +fn has_recent_resultant_force(values: &[f32]) -> bool { + values + .iter() + .rev() + .take(FORCE_PANEL_ACTIVE_TAIL) + .any(|value| *value > 0.0) +} + +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 first_row_y = screen.bottom() + - HAND_FORCE_PANEL_BOTTOM_MARGIN + - HAND_FORCE_PANEL_HEIGHT * 2.0 + - HAND_FORCE_PANEL_GAP; + let second_row_y = screen.bottom() - HAND_FORCE_PANEL_BOTTOM_MARGIN - HAND_FORCE_PANEL_HEIGHT; + + for index in 0..HAND_FORCE_PANEL_TITLES.len() { + let history = histories.get(index).map(Vec::as_slice).unwrap_or(&[]); + let (code, title) = HAND_FORCE_PANEL_TITLES[index]; + let row_count: usize = if index < 4 { 4 } else { 3 }; + let row_index = if index < 4 { index } else { index - 4 }; + let total_width = row_count as f32 * HAND_FORCE_PANEL_WIDTH + + (row_count.saturating_sub(1)) as f32 * HAND_FORCE_PANEL_GAP; + let row_left = screen.center().x - total_width * 0.5; + let target_x = + row_left + row_index as f32 * (HAND_FORCE_PANEL_WIDTH + HAND_FORCE_PANEL_GAP); + let target_y = if index < 4 { first_row_y } else { second_row_y }; + + draw_force_chart_area( + ctx, + egui::Id::new(format!("hand_force_panel_{index}")), + egui::pos2(target_x, target_y), + egui::vec2(HAND_FORCE_PANEL_WIDTH, HAND_FORCE_PANEL_HEIGHT), + target_visible, + code, + title, + history, + ); + } +} + +fn draw_force_chart_area( + ctx: &egui::Context, + id: egui::Id, + target_pos: egui::Pos2, + size: egui::Vec2, + target_visible: bool, + code: &'static str, + title: &'static str, values: &[f32], - spatial_force: Option, ) { - let latest = values.last().copied().unwrap_or(0.0); - let max = values.iter().copied().fold(0.0_f32, f32::max); - let active_values = values.iter().copied().filter(|value| *value > 0.0); - let min = active_values.fold(None, |best: Option, value| { - Some(best.map_or(value, |current| current.min(value))) - }); + let anim = ctx.animate_bool(id.with("enter"), target_visible); - group_frame().show(ui, |ui| { - ui.horizontal(|ui| { - ui.colored_label(ACCENT_BLUE, "RF"); - ui.label(style::panel_title("合力")); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.colored_label(ACCENT_GREEN, format!("{latest:.1} N")); + if anim <= 0.01 { + return; + } + + let target_anim = if target_visible { 1.0 } else { 0.0 }; + if (anim - target_anim).abs() > 0.001 { + ctx.request_repaint(); + } + + 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); + + egui::Area::new(id) + .fixed_pos(egui::pos2(target_pos.x, y)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + panel_frame(ctx).show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); + ui.set_min_width(size.x); + ui.set_min_height(size.y); + draw_force_chart_panel_contents(ui, code, title, values, size.y); }); }); +} - ui.add_space(4.0); - ui.horizontal(|ui| { - force_metric(ui, "峰值", max); - ui.separator(); - force_metric(ui, "低值", min.unwrap_or(0.0)); - ui.separator(); - ui.colored_label(dim_text(), format!("{} 点", values.len())); +fn draw_force_chart_panel_contents( + ui: &mut egui::Ui, + code: &'static str, + title: &'static str, + values: &[f32], + content_height: f32, +) { + let latest = values.last().copied().unwrap_or(0.0); + + ui.horizontal(|ui| { + ui.colored_label(ACCENT_BLUE, code); + ui.label(style::panel_title(title)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.colored_label(ACCENT_GREEN, format!("{latest:.1} N")); }); - - ui.add_space(6.0); - paint_resultant_force_chart(ui, values); - - ui.add_space(8.0); - draw_spatial_force_readout(ui, spatial_force); }); + + ui.add_space(6.0); + let chart_height = (content_height - 32.0).max(90.0); + paint_resultant_force_chart(ui, values, chart_height); } -fn draw_spatial_force_readout(ui: &mut egui::Ui, spatial_force: Option) { - let Some(force) = spatial_force else { - ui.horizontal(|ui| { - ui.colored_label(dim_text(), "3D"); - ui.label(style::subtle_text("等待三维力数据")); - }); - return; - }; - - ui.horizontal_wrapped(|ui| { - ui.colored_label(ACCENT_BLUE, "3D"); - ui.label(style::value_text(format!("angle {:.0}°", force.angle_deg))); - ui.separator(); - ui.label(style::value_text(format!("mag {:.2}", force.magnitude))); - }); -} - -fn force_metric(ui: &mut egui::Ui, label: &'static str, value: f32) { - ui.colored_label(dim_text(), label); - ui.label(style::value_text(format!("{value:.1} N"))); -} - -fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) { - const CHART_HEIGHT: f32 = 138.0; +fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height: f32) { const CHART_POINTS: usize = 42; + const CHART_WINDOW_SECONDS: f32 = CHART_POINTS as f32 * FORCE_CHART_SAMPLE_SECONDS; let width = ui.available_width().max(280.0); - let (rect, _) = ui.allocate_exact_size(egui::vec2(width, CHART_HEIGHT), egui::Sense::hover()); + 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); + let time = ui.ctx().input(|input| input.time) as f32; painter.rect_filled(rect, radius, egui::Color32::from_rgb(10, 18, 22)); painter.rect_stroke( @@ -789,7 +886,7 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) { rect.right_bottom() - egui::vec2(10.0, 24.0), ); - paint_force_grid(&painter, rect, chart_rect); + paint_force_grid(&painter, rect, chart_rect, time, CHART_WINDOW_SECONDS); if values.is_empty() { painter.text( @@ -804,12 +901,13 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) { ui.ctx().request_repaint(); - let time = ui.ctx().input(|input| input.time) as f32; let enter = ui .ctx() .animate_bool(egui::Id::new("resultant_force_chart_enter"), true); let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65; - let plot_rect = chart_rect.translate(egui::vec2(slide_offset, 0.0)); + let tick_phase = (time / FORCE_CHART_SAMPLE_SECONDS).fract(); + let scroll_offset = -tick_phase * chart_rect.width() / CHART_POINTS.max(1) as f32; + let plot_rect = chart_rect.translate(egui::vec2(slide_offset + scroll_offset, 0.0)); let mut points = Vec::with_capacity(values.len()); let start_slot = CHART_POINTS.saturating_sub(values.len()); @@ -852,7 +950,13 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) { } } -fn paint_force_grid(painter: &egui::Painter, rect: egui::Rect, chart_rect: egui::Rect) { +fn paint_force_grid( + painter: &egui::Painter, + rect: egui::Rect, + chart_rect: egui::Rect, + time: f32, + window_seconds: f32, +) { for tick in [25.0, 20.0, 15.0, 10.0, 5.0, 0.0] { let normalized = (tick / FORCE_CHART_MAX_N).clamp(0.0, 1.0); let y = chart_rect.bottom() - normalized * chart_rect.height(); @@ -883,20 +987,25 @@ fn paint_force_grid(painter: &egui::Painter, rect: egui::Rect, chart_rect: egui: ); } - painter.text( - egui::pos2(chart_rect.left(), rect.bottom() - 11.0), - egui::Align2::LEFT_CENTER, - "-4.2s", - egui::FontId::monospace(9.0), - ONE_DARK_PRO.text_subtle, - ); - painter.text( - egui::pos2(chart_rect.right(), rect.bottom() - 11.0), - egui::Align2::RIGHT_CENTER, - "now", - egui::FontId::monospace(9.0), - ONE_DARK_PRO.text_subtle, - ); + let start_time = (time - window_seconds).max(0.0); + for index in 0..=5 { + let fraction = index as f32 / 5.0; + let x = chart_rect.left() + fraction * chart_rect.width(); + let label_time = start_time + fraction * (time - start_time); + let align = match index { + 0 => egui::Align2::LEFT_CENTER, + 5 => egui::Align2::RIGHT_CENTER, + _ => egui::Align2::CENTER_CENTER, + }; + + painter.text( + egui::pos2(x, rect.bottom() - 11.0), + align, + format!("{label_time:.1}s"), + egui::FontId::monospace(9.0), + ONE_DARK_PRO.text_subtle, + ); + } } fn paint_force_sweep(painter: &egui::Painter, chart_rect: egui::Rect, time: f32) { @@ -1613,9 +1722,15 @@ pub fn draw_matrix_config_panel( } pub fn panel_restore_item(ui: &mut egui::Ui, title: &'static str, panel: &mut FloatingPanelState) { + let button_size = egui::vec2(ui.available_width(), 0.0); if panel.visible { - ui.add_enabled(false, egui::Button::new(format!("{title} 已显示"))); - } else if ui.button(format!("显示 {title}")).clicked() { + ui.add_enabled_ui(false, |ui| { + ui.add_sized(button_size, egui::Button::new(format!("{title} 已显示"))); + }); + } else if ui + .add_sized(button_size, egui::Button::new(format!("显示 {title}"))) + .clicked() + { panel.visible = true; ui.close(); }