4 Commits

8 changed files with 1974 additions and 312 deletions

1060
docs/cargo-wix-guide.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,16 +13,19 @@ use crate::{
},
ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
draw_config_panel, draw_export_panel, draw_hand_force_panels, draw_matrix_config_panel,
draw_stats_panel, panel_restore_item,
draw_config_panel, draw_spatial_force_panel, draw_stats_panel, panel_restore_item,
},
};
use eframe::{egui, egui_wgpu};
use std::sync::Arc;
use std::{
sync::Arc,
time::{Duration, Instant},
};
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 RATE_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
pub struct EskinDesktopApp {
connect_panel: FloatingPanelState,
@@ -44,16 +47,61 @@ pub struct EskinDesktopApp {
hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT],
force_estimator: ForceEstimatorState,
latest_spatial_force: Option<HudSpatialForce>,
spatial_force_panel_force: Option<HudSpatialForce>,
latest_raw_matrix: Vec<u32>,
latest_matrix_rows: u32,
latest_matrix_cols: u32,
breakout_visible: bool,
breakout_game: BreakoutGame,
live_rates: LiveRateMeters,
context_menu_open: bool,
context_menu_pos: egui::Pos2,
active_mode: ActiveMode,
}
struct LiveRateMeters {
window_started_at: Instant,
window_rx_frames: u64,
window_render_frames: u32,
sample_rate_hz: f32,
render_rate_hz: f32,
}
impl LiveRateMeters {
fn new() -> Self {
Self {
window_started_at: Instant::now(),
window_rx_frames: 0,
window_render_frames: 0,
sample_rate_hz: 0.0,
render_rate_hz: 0.0,
}
}
fn tick_render(&mut self, total_rx_frames: u64) {
if total_rx_frames < self.window_rx_frames {
self.window_rx_frames = total_rx_frames;
self.sample_rate_hz = 0.0;
}
self.window_render_frames = self.window_render_frames.saturating_add(1);
let now = Instant::now();
let elapsed = now.duration_since(self.window_started_at);
if elapsed < RATE_UPDATE_INTERVAL {
return;
}
let seconds = elapsed.as_secs_f32().max(0.001);
let rx_delta = total_rx_frames.saturating_sub(self.window_rx_frames);
self.sample_rate_hz = rx_delta as f32 / seconds;
self.render_rate_hz = self.window_render_frames as f32 / seconds;
self.window_started_at = now;
self.window_rx_frames = total_rx_frames;
self.window_render_frames = 0;
}
}
impl EskinDesktopApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
egui_extras::install_image_loaders(&cc.egui_ctx);
@@ -110,11 +158,13 @@ impl EskinDesktopApp {
hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)),
force_estimator: ForceEstimatorState::new(),
latest_spatial_force: None,
spatial_force_panel_force: None,
latest_raw_matrix: Vec::new(),
latest_matrix_rows: MATRIX_ROWS,
latest_matrix_cols: MATRIX_COLS,
breakout_visible: false,
breakout_game: BreakoutGame::default(),
live_rates: LiveRateMeters::new(),
context_menu_open: false,
context_menu_pos: egui::pos2(24.0, 48.0),
active_mode: ActiveMode::Finger(FingerMode {
@@ -173,19 +223,23 @@ impl EskinDesktopApp {
}
fn draw_workspace(&mut self, ui: &mut egui::Ui) {
let screen = ui.max_rect();
let workspace = egui::Rect::from_min_max(
screen.left_top() + egui::vec2(0.0, layout::WORKSPACE_TOP),
screen.right_bottom(),
);
if self.breakout_visible {
self.draw_split_workspace(ui);
self.draw_split_workspace(ui, workspace);
} else {
self.draw_wgpu_background_rect(ui, ui.max_rect());
ui.painter()
.rect_filled(screen, egui::CornerRadius::ZERO, ONE_DARK_PRO.bg);
self.draw_wgpu_background_rect(ui, workspace);
}
}
fn draw_split_workspace(&mut self, ui: &mut egui::Ui) {
fn draw_split_workspace(&mut self, ui: &mut egui::Ui, content: egui::Rect) {
let screen = ui.max_rect();
let content = egui::Rect::from_min_max(
screen.left_top() + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT),
screen.right_bottom(),
);
let gutter = 10.0;
let half_width = (content.width() - gutter).max(1.0) * 0.5;
let left = egui::Rect::from_min_size(content.min, egui::vec2(half_width, content.height()));
@@ -230,9 +284,6 @@ impl EskinDesktopApp {
self.latest_matrix_rows = sample.rows;
self.latest_matrix_cols = sample.cols;
// Feed data to recorder
self.recorder.add_frame(&sample.matrix);
self.latest_spatial_force = self.force_estimator.analyze(&sample.matrix);
// Keep JE-Skin's summary path separate from the optional spatial-force vector.
@@ -365,36 +416,65 @@ impl EskinDesktopApp {
}
}
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
// draw_scene_panel(ctx, &mut self.scene_panel);
fn draw_floating_panels(
&mut self,
ctx: &egui::Context,
stats: crate::serial_core::serial::SerialIoStats,
) {
self.scene_panel.visible = false;
self.connect_panel.visible = false;
self.export_panel.visible = false;
self.matrix_config_panel.visible = false;
self.context_menu_open = false;
if let Some(next_mode) = draw_config_panel(
ctx,
&mut self.config_panel,
&mut self.config_state,
&self.connection,
&self.recorder,
stats,
self.live_rates.sample_rate_hz,
self.live_rates.render_rate_hz,
) {
if next_mode == SerialMode::Hand {
self.config_state.mode = SerialMode::Finger;
self.connect_state.mode = SerialMode::Finger;
self.config_state.baud_rate = SerialMode::Finger.baud_rate();
self.active_mode = ActiveMode::Finger(FingerMode {
rows: self.matrix_config.rows,
cols: self.matrix_config.cols,
range: 0..7000,
dot: true,
});
self.breakout_visible = true;
} else {
self.switch_mode(next_mode);
}
match self.config_state.mode {
SerialMode::Finger => {
}
if self.breakout_visible {
self.stats_panel.visible = false;
return;
}
if self.config_state.mode == SerialMode::Finger {
self.stats_panel.visible = true;
draw_stats_panel(
ctx,
&mut self.stats_panel,
&self.signal_history,
self.latest_spatial_force,
);
} else {
self.stats_panel.visible = false;
}
SerialMode::Hand => {
draw_hand_force_panels(ctx, self.stats_panel.visible, &self.hand_signal_histories);
if let Some(force) = self.latest_spatial_force {
self.spatial_force_panel_force = Some(force);
}
}
draw_export_panel(
ctx,
&mut self.export_panel,
&self.recorder,
&mut self.export_path,
);
draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
draw_spatial_force_panel(ctx, self.spatial_force_panel_force, &self.signal_history);
}
fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
@@ -430,7 +510,20 @@ impl EskinDesktopApp {
panel_restore_item(ui, "配置", &mut self.config_panel);
panel_restore_item(ui, "录制", &mut self.export_panel);
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
if self.stats_panel.visible {
if ui
.add_sized(
egui::vec2(ui.available_width(), 0.0),
egui::Button::new("隐藏 统计"),
)
.clicked()
{
self.stats_panel.visible = false;
close_menu = true;
}
} else {
panel_restore_item(ui, "统计", &mut self.stats_panel);
}
if ui
.add_sized(
egui::vec2(ui.available_width(), 0.0),
@@ -482,6 +575,7 @@ impl EskinDesktopApp {
self.hand_pressure.clear();
self.force_estimator.reset();
self.latest_spatial_force = None;
self.spatial_force_panel_force = None;
self.signal_history.clear();
self.hand_signal_histories
.iter_mut()
@@ -646,12 +740,13 @@ fn normalize_pressure_value(value: u32) -> [f32; 2] {
impl eframe::App for EskinDesktopApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let stats = self.connection.stats();
self.live_rates.tick_render(stats.rx_frames);
self.update_pressure_matrix();
self.draw_workspace(ui);
self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx);
self.draw_panel_context_menu(&ctx);
self.draw_floating_panels(&ctx, stats);
// Keep repainting while the wgpu background is a realtime viewport.
ctx.request_repaint();

View File

@@ -20,6 +20,7 @@ enum BreakoutPhase {
Idle,
Running,
Paused,
Won,
Over,
}
@@ -46,7 +47,6 @@ pub struct BreakoutGame {
score: u32,
combo: u32,
lives: u32,
level: u32,
last_time: Option<f64>,
previous_pause_gesture: bool,
pause_locked_until: f64,
@@ -63,7 +63,6 @@ impl Default for BreakoutGame {
score: 0,
combo: 0,
lives: 3,
level: 1,
last_time: None,
previous_pause_gesture: false,
pause_locked_until: 0.0,
@@ -150,7 +149,6 @@ impl BreakoutGame {
status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN);
status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE);
status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED);
status_chip(ui, "关卡", self.level.to_string(), ACCENT_BLUE);
});
ui.add_space(7.0);
@@ -191,12 +189,11 @@ impl BreakoutGame {
self.score = 0;
self.combo = 0;
self.lives = 3;
self.level = 1;
self.rebuild_bricks();
}
fn start(&mut self) {
if self.phase == BreakoutPhase::Over {
if self.phase == BreakoutPhase::Over || self.phase == BreakoutPhase::Won {
self.reset();
}
if self.phase != BreakoutPhase::Running {
@@ -219,7 +216,10 @@ impl BreakoutGame {
let threshold = pressure_pause_threshold();
let active = top_force >= threshold;
if active && !self.previous_pause_gesture && now >= self.pause_locked_until {
if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Over {
if matches!(
self.phase,
BreakoutPhase::Idle | BreakoutPhase::Over | BreakoutPhase::Won
) {
self.start();
} else {
self.toggle_pause();
@@ -249,7 +249,7 @@ impl BreakoutGame {
}
fn launch_ball(&mut self) {
let direction = if self.level % 2 == 0 { -0.24 } else { 0.24 };
let direction = 0.24;
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED;
}
@@ -324,9 +324,8 @@ impl BreakoutGame {
}
if self.bricks.iter().all(|brick| !brick.alive) {
self.level += 1;
self.rebuild_bricks();
self.launch_ball();
self.phase = BreakoutPhase::Won;
self.ball_vel = egui::Vec2::ZERO;
}
}
@@ -358,6 +357,7 @@ impl BreakoutGame {
BreakoutPhase::Idle => "待机",
BreakoutPhase::Running => "运行",
BreakoutPhase::Paused => "暂停",
BreakoutPhase::Won => "过关",
BreakoutPhase::Over => "结束",
}
}
@@ -387,6 +387,7 @@ impl BreakoutGame {
if self.phase == BreakoutPhase::Idle
|| self.phase == BreakoutPhase::Paused
|| self.phase == BreakoutPhase::Won
|| self.phase == BreakoutPhase::Over
{
paint_center_overlay(&painter, rect, self.phase);
@@ -403,6 +404,10 @@ pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl
let cols = cols.max(1) as usize;
let sample_rows = rows.min(2);
let sample_cols = cols.min(2);
let top_gesture_rows = rows.min(1);
let top_gesture_cols = (cols / 3).clamp(1, cols);
let top_gesture_col_start = (cols - top_gesture_cols) / 2;
let top_gesture_col_end = top_gesture_col_start + top_gesture_cols;
let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 {
let mut sum = 0.0;
let mut count = 0.0;
@@ -430,7 +435,12 @@ pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl
let left_force = tl + bl;
let right_force = tr + br;
let top_force = tl + tr;
let top_force = avg(
0,
top_gesture_rows,
top_gesture_col_start,
top_gesture_col_end,
);
let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22);
let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0);
let axis = if raw_axis.abs() < 0.045 {
@@ -485,6 +495,7 @@ fn status_color(phase: BreakoutPhase) -> egui::Color32 {
BreakoutPhase::Idle => ONE_DARK_PRO.text_dim,
BreakoutPhase::Running => ACCENT_GREEN,
BreakoutPhase::Paused => ACCENT_ORANGE,
BreakoutPhase::Won => ACCENT_GREEN,
BreakoutPhase::Over => ACCENT_RED,
}
}
@@ -607,6 +618,7 @@ fn paint_center_overlay(painter: &egui::Painter, rect: egui::Rect, phase: Breako
let (title, detail) = match phase {
BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"),
BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"),
BreakoutPhase::Won => ("恭喜过关", "按压顶部、点击或空格重新开始"),
BreakoutPhase::Over => ("游戏结束", "点击或空格重开"),
BreakoutPhase::Running => ("", ""),
};

View File

@@ -4,6 +4,7 @@ use std::time::Duration;
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
use crate::recording::Recorder;
use crate::serial_core::serial::{
SerialIoStats, SerialPortReadWrite, SerialProtocol, run_serial_loop,
};
@@ -83,6 +84,7 @@ impl ConnectionManager {
cols: u32,
baud_rate: u32,
protocol: SerialProtocol,
recorder: Recorder,
) {
self.disconnect();
self.set_state(ConnectionState::Connecting);
@@ -108,6 +110,7 @@ impl ConnectionManager {
&sample_tx,
&stats_tx,
&latest_sample,
recorder,
);
if let Err(e) = result {
eprintln!("[connection] device loop error: {e}");
@@ -187,9 +190,10 @@ fn run_device_loop(
sample_tx: &Sender<Vec<i32>>,
stats_tx: &Sender<SerialIoStats>,
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
recorder: Recorder,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let port = serialport::new(port_name, baud_rate)
.timeout(Duration::from_millis(100))
.timeout(Duration::from_millis(1))
.open()?;
*state.lock().unwrap() = ConnectionState::Connected;
@@ -205,6 +209,7 @@ fn run_device_loop(
cancel_rx,
sample_tx,
Some(stats_tx),
Some(&recorder),
);
if let Ok(mut latest) = latest_sample.lock() {

View File

@@ -1,3 +1,4 @@
use crate::recording::Recorder;
use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::hand_gateway::{HandGatewayCodec, HandGatewayFrame};
use crate::serial_core::codecs::tactile_a::TactileACodec;
@@ -6,13 +7,14 @@ use crossbeam_channel::{Receiver, Sender};
use std::io::{Read, Write};
use std::time::{Duration, Instant};
const POLL_INTERVAL_MS: u64 = 10;
const POLL_INTERVAL_MS: u64 = 5;
const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70, 44];
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SerialIoStats {
pub rx_bytes: u64,
pub tx_bytes: u64,
pub rx_frames: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,12 +33,15 @@ pub fn run_serial_loop(
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) {
match protocol {
SerialProtocol::TactileA => {
run_tactile_a_loop(port, rows, cols, cancel_rx, sample_tx, stats_tx)
run_tactile_a_loop(port, rows, cols, cancel_rx, sample_tx, stats_tx, recorder)
}
SerialProtocol::HandGateway => {
run_hand_gateway_loop(port, cancel_rx, sample_tx, stats_tx, recorder)
}
SerialProtocol::HandGateway => run_hand_gateway_loop(port, cancel_rx, sample_tx, stats_tx),
}
}
@@ -47,6 +52,7 @@ fn run_tactile_a_loop(
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) {
let session_started_at = Instant::now();
let mut codec = TactileACodec::new(cols, rows);
@@ -90,7 +96,14 @@ fn run_tactile_a_loop(
for frame in frames {
if let TactileAFrame::Rep(rep) = frame {
if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) {
if let Some(recorder) = recorder {
let pressures: Vec<u32> =
vals.iter().map(|v| (*v).max(0) as u32).collect();
recorder.add_frame(&pressures);
}
let _ = sample_tx.try_send(vals);
io_stats.rx_frames += 1;
publish_stats(stats_tx, io_stats);
}
}
}
@@ -116,6 +129,7 @@ fn run_hand_gateway_loop(
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) {
let session_started_at = Instant::now();
let mut codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS);
@@ -174,7 +188,14 @@ fn run_hand_gateway_loop(
if parse_ok {
// println!("[hand-packet-values] samples={}", vals.len());
if let Some(recorder) = recorder {
let pressures: Vec<u32> =
vals.iter().map(|v| (*v).max(0) as u32).collect();
recorder.add_frame(&pressures);
}
let _ = sample_tx.try_send(vals);
io_stats.rx_frames += 1;
publish_stats(stats_tx, io_stats);
}
}
}

View File

@@ -1,4 +1,4 @@
use eframe::egui;
use eframe::egui::{self, Color32};
#[derive(Clone, Copy)]
pub struct AppTheme {
@@ -61,6 +61,8 @@ pub const METRICS: DesignMetrics = DesignMetrics {
pub mod layout {
pub const TITLE_BAR_HEIGHT: f32 = 36.0;
pub const CONFIG_BAR_HEIGHT: f32 = 48.0;
pub const WORKSPACE_TOP: f32 = TITLE_BAR_HEIGHT + CONFIG_BAR_HEIGHT;
pub const CENTER_PANEL_TOP: f32 = 48.0;
pub const LEFT_X: f32 = 24.0;
pub const RIGHT_X: f32 = 1328.0;
@@ -213,6 +215,19 @@ pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
.min_size(egui::vec2(0.0, METRICS.button_height))
}
pub fn rich_tag_button(
label: impl Into<String>,
color: impl Into<Color32>,
) -> egui::Button<'static> {
let text = egui::RichText::new(label.into()).color(color);
egui::Button::new(text)
.fill(ONE_DARK_PRO.panel_strong)
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
.corner_radius(egui::CornerRadius::same(4))
.min_size(egui::vec2(0.0, METRICS.button_height))
}
pub fn primary_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
egui::Button::new(label)
.fill(ONE_DARK_PRO.accent)

728
src/ui.rs

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,8 @@
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perMachine'
InstallScope='perUser'
InstallPrivileges='limited'
SummaryCodepage='1252'
/>
@@ -86,7 +87,7 @@
<Property Id='DiskPrompt' Value='eskin-model-player Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
<Directory Id='LocalAppDataFolder'>
<Directory Id='APPLICATIONFOLDER' Name='eskin-model-player'>
<!--
@@ -110,7 +111,16 @@
-->
<Directory Id='Bin' Name='bin'>
<Component Id='Path' Guid='BAD34BAB-C54B-4967-880C-5A180533BD3F' KeyPath='yes'>
<Component Id='Path' Guid='FD230ABD-75B9-47D4-870B-CF2EA72B76BE'>
<RegistryValue
Root='HKCU'
Key='Software\JOYSONQUIN\eskin-model-player'
Name='PathComponent'
Type='integer'
Value='1'
KeyPath='yes'/>
<RemoveFolder Id='RemoveBinFolder' Directory='Bin' On='uninstall'/>
<RemoveFolder Id='RemoveApplicationFolder' Directory='APPLICATIONFOLDER' On='uninstall'/>
<Environment
Id='PATH'
Name='PATH'
@@ -118,15 +128,21 @@
Permanent='no'
Part='last'
Action='set'
System='yes'/>
System='no'/>
</Component>
<Component Id='binary0' Guid='*'>
<Component Id='binary0' Guid='302CAFB5-6951-426B-BC5A-988C351A2CF2'>
<RegistryValue
Root='HKCU'
Key='Software\JOYSONQUIN\eskin-model-player'
Name='BinaryComponent'
Type='integer'
Value='1'
KeyPath='yes'/>
<File
Id='exe0'
Name='ESkinPlayer.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\ESkinPlayer.exe'
KeyPath='yes'/>
Source='$(var.CargoTargetBinDir)\ESkinPlayer.exe'/>
</Component>
</Directory>
</Directory>