6 Commits

Author SHA1 Message Date
lenn
9efe360a1d Refine finger mode UI: app wiring, style helpers, and panel layout 2026-07-06 10:32:46 +08:00
lenn
71d314ac44 Add finger mode UI and refine breakout/serial integration 2026-07-02 15:22:13 +08:00
lenn
0a9fea0f0a Improve panel layout and per-user installer 2026-07-02 13:38:00 +08:00
lenn
da77f2f194 Improve hand mode panels and recording 2026-07-01 17:08:07 +08:00
lenn
1ed729f8da Prepare Windows installer release 2026-06-30 17:43:02 +08:00
lenn
444c20f233 Add animated force panels 2026-06-30 11:10:29 +08:00
13 changed files with 2432 additions and 396 deletions

View File

@@ -1,35 +0,0 @@
{
"hooks": {
"pre-exec": [
{
"matcher": "",
"command": "scale gate pre-tool Bash --args-json \"$ARGS\" --session-id \"$SESSION_ID\""
},
{
"matcher": "edit|write",
"command": "scale gate pre-tool Edit --args-json \"$ARGS\" --session-id \"$SESSION_ID\""
}
],
"post-exec": [
{
"matcher": "edit|write",
"command": "scale gate post-tool Edit --args-json \"$ARGS\" --exit-code \"$EXIT_CODE\" --session-id \"$SESSION_ID\""
},
{
"matcher": "",
"command": "scale gate post-tool Bash --args-json \"$ARGS\" --exit-code \"$EXIT_CODE\" --session-id \"$SESSION_ID\""
}
],
"before-stop": [
{
"matcher": "",
"command": "scale gate before-stop --session-id \"$SESSION_ID\""
}
]
},
"permissions": {
"allow": [
"scale:*"
]
}
}

2
Cargo.lock generated
View File

@@ -1304,7 +1304,7 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]] [[package]]
name = "eskin-model-player" name = "eskin-model-player"
version = "0.5.0" version = "5.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytemuck", "bytemuck",

View File

@@ -1,9 +1,14 @@
[package] [package]
name = "eskin-model-player" name = "eskin-model-player"
version = "0.5.0" version = "5.0.0"
edition = "2024" edition = "2024"
authors = ["JOYSONQUIN"]
description = "Desktop pressure sensor visualization and playback application."
build = "build.rs" build = "build.rs"
[package.metadata.wix]
eula = false
[dependencies] [dependencies]
eframe = { version = "0.34.2", features = ["default", "wgpu", "__screenshot"] } eframe = { version = "0.34.2", features = ["default", "wgpu", "__screenshot"] }
env_logger = { version = "0.11.10", features = ["auto-color", "humantime"] } env_logger = { version = "0.11.10", features = ["auto-color", "humantime"] }
@@ -22,3 +27,8 @@ gltf = "1.4.1"
[build-dependencies] [build-dependencies]
anyhow = "1.0.102" anyhow = "1.0.102"
fs_extra = "1.3.0" fs_extra = "1.3.0"
[[bin]]
name = "ESkinPlayer"
path = "src/main.rs"

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,14 +13,19 @@ use crate::{
}, },
ui::{ ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState, ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
draw_config_panel, draw_export_panel, draw_matrix_config_panel, draw_stats_panel, draw_config_panel, draw_spatial_force_panel, draw_stats_panel, panel_restore_item,
panel_restore_item,
}, },
}; };
use eframe::{egui, egui_wgpu}; use eframe::{egui, egui_wgpu};
use std::sync::Arc; use std::{
sync::Arc,
time::{Duration, Instant},
};
const SUMMARY_POINTS_PER_SERIES: usize = 42; 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 { pub struct EskinDesktopApp {
connect_panel: FloatingPanelState, connect_panel: FloatingPanelState,
@@ -39,18 +44,64 @@ pub struct EskinDesktopApp {
matrix_config_panel: FloatingPanelState, matrix_config_panel: FloatingPanelState,
matrix_config: MatrixConfigState, matrix_config: MatrixConfigState,
signal_history: Vec<f32>, signal_history: Vec<f32>,
hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT],
force_estimator: ForceEstimatorState, force_estimator: ForceEstimatorState,
latest_spatial_force: Option<HudSpatialForce>, latest_spatial_force: Option<HudSpatialForce>,
spatial_force_panel_force: Option<HudSpatialForce>,
latest_raw_matrix: Vec<u32>, latest_raw_matrix: Vec<u32>,
latest_matrix_rows: u32, latest_matrix_rows: u32,
latest_matrix_cols: u32, latest_matrix_cols: u32,
breakout_visible: bool, breakout_visible: bool,
breakout_game: BreakoutGame, breakout_game: BreakoutGame,
live_rates: LiveRateMeters,
context_menu_open: bool, context_menu_open: bool,
context_menu_pos: egui::Pos2, context_menu_pos: egui::Pos2,
active_mode: ActiveMode, 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 { impl EskinDesktopApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self { pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
egui_extras::install_image_loaders(&cc.egui_ctx); egui_extras::install_image_loaders(&cc.egui_ctx);
@@ -104,13 +155,16 @@ impl EskinDesktopApp {
), ),
matrix_config: MatrixConfigState::default(), matrix_config: MatrixConfigState::default(),
signal_history: Vec::with_capacity(128), signal_history: Vec::with_capacity(128),
hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)),
force_estimator: ForceEstimatorState::new(), force_estimator: ForceEstimatorState::new(),
latest_spatial_force: None, latest_spatial_force: None,
spatial_force_panel_force: None,
latest_raw_matrix: Vec::new(), latest_raw_matrix: Vec::new(),
latest_matrix_rows: MATRIX_ROWS, latest_matrix_rows: MATRIX_ROWS,
latest_matrix_cols: MATRIX_COLS, latest_matrix_cols: MATRIX_COLS,
breakout_visible: false, breakout_visible: false,
breakout_game: BreakoutGame::default(), breakout_game: BreakoutGame::default(),
live_rates: LiveRateMeters::new(),
context_menu_open: false, context_menu_open: false,
context_menu_pos: egui::pos2(24.0, 48.0), context_menu_pos: egui::pos2(24.0, 48.0),
active_mode: ActiveMode::Finger(FingerMode { active_mode: ActiveMode::Finger(FingerMode {
@@ -169,19 +223,23 @@ impl EskinDesktopApp {
} }
fn draw_workspace(&mut self, ui: &mut egui::Ui) { 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 { if self.breakout_visible {
self.draw_split_workspace(ui); self.draw_split_workspace(ui, workspace);
} else { } 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 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 gutter = 10.0;
let half_width = (content.width() - gutter).max(1.0) * 0.5; 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())); let left = egui::Rect::from_min_size(content.min, egui::vec2(half_width, content.height()));
@@ -226,9 +284,6 @@ impl EskinDesktopApp {
self.latest_matrix_rows = sample.rows; self.latest_matrix_rows = sample.rows;
self.latest_matrix_cols = sample.cols; 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); self.latest_spatial_force = self.force_estimator.analyze(&sample.matrix);
// Keep JE-Skin's summary path separate from the optional spatial-force vector. // Keep JE-Skin's summary path separate from the optional spatial-force vector.
@@ -243,6 +298,10 @@ impl EskinDesktopApp {
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES { if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
self.signal_history.remove(0); self.signal_history.remove(0);
} }
if self.config_state.mode == SerialMode::Hand {
update_hand_signal_histories(&mut self.hand_signal_histories, &sample.matrix);
}
} }
} }
@@ -357,29 +416,65 @@ impl EskinDesktopApp {
} }
} }
fn draw_floating_panels(&mut self, ctx: &egui::Context) { fn draw_floating_panels(
// draw_scene_panel(ctx, &mut self.scene_panel); &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( if let Some(next_mode) = draw_config_panel(
ctx, ctx,
&mut self.config_panel, &mut self.config_panel,
&mut self.config_state, &mut self.config_state,
&self.connection, &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); self.switch_mode(next_mode);
} }
}
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( draw_stats_panel(
ctx, ctx,
&mut self.stats_panel, &mut self.stats_panel,
&self.signal_history, &self.signal_history,
self.latest_spatial_force, self.latest_spatial_force,
); );
draw_export_panel( } else {
ctx, self.stats_panel.visible = false;
&mut self.export_panel, }
&self.recorder,
&mut self.export_path, if let Some(force) = self.latest_spatial_force {
); self.spatial_force_panel_force = Some(force);
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) { fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
@@ -415,15 +510,40 @@ impl EskinDesktopApp {
panel_restore_item(ui, "配置", &mut self.config_panel); panel_restore_item(ui, "配置", &mut self.config_panel);
panel_restore_item(ui, "录制", &mut self.export_panel); panel_restore_item(ui, "录制", &mut self.export_panel);
panel_restore_item(ui, "矩阵", &mut self.matrix_config_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); 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; self.breakout_visible = true;
close_menu = true; close_menu = true;
} }
ui.separator(); 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.config_panel.visible = true;
self.export_panel.visible = true; self.export_panel.visible = true;
self.matrix_config_panel.visible = true; self.matrix_config_panel.visible = true;
@@ -455,7 +575,11 @@ impl EskinDesktopApp {
self.hand_pressure.clear(); self.hand_pressure.clear();
self.force_estimator.reset(); self.force_estimator.reset();
self.latest_spatial_force = None; self.latest_spatial_force = None;
self.spatial_force_panel_force = None;
self.signal_history.clear(); self.signal_history.clear();
self.hand_signal_histories
.iter_mut()
.for_each(|history| history.clear());
self.active_mode = match next { self.active_mode = match next {
SerialMode::Finger => ActiveMode::Finger(FingerMode { SerialMode::Finger => ActiveMode::Finger(FingerMode {
@@ -469,6 +593,30 @@ impl EskinDesktopApp {
} }
} }
fn update_hand_signal_histories(
histories: &mut [Vec<f32>; 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 { fn raw_to_g1(raw: u32) -> f32 {
const RAW: [u32; 12] = [ const RAW: [u32; 12] = [
0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444, 0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444,
@@ -592,12 +740,13 @@ fn normalize_pressure_value(value: u32) -> [f32; 2] {
impl eframe::App for EskinDesktopApp { 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(); let ctx = ui.ctx().clone();
let stats = self.connection.stats();
self.live_rates.tick_render(stats.rx_frames);
self.update_pressure_matrix(); self.update_pressure_matrix();
self.draw_workspace(ui); self.draw_workspace(ui);
self.draw_title_bar(ui, frame); self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx); self.draw_floating_panels(&ctx, stats);
self.draw_panel_context_menu(&ctx);
// Keep repainting while the wgpu background is a realtime viewport. // Keep repainting while the wgpu background is a realtime viewport.
ctx.request_repaint(); ctx.request_repaint();

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
#![allow(dead_code)] #![allow(dead_code)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app; mod app;
mod breakout; mod breakout;
mod connection; mod connection;

View File

@@ -1,3 +1,4 @@
use crate::recording::Recorder;
use crate::serial_core::codec::Codec; use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::hand_gateway::{HandGatewayCodec, HandGatewayFrame}; use crate::serial_core::codecs::hand_gateway::{HandGatewayCodec, HandGatewayFrame};
use crate::serial_core::codecs::tactile_a::TactileACodec; use crate::serial_core::codecs::tactile_a::TactileACodec;
@@ -6,13 +7,14 @@ use crossbeam_channel::{Receiver, Sender};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::time::{Duration, Instant}; 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]; const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70, 44];
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SerialIoStats { pub struct SerialIoStats {
pub rx_bytes: u64, pub rx_bytes: u64,
pub tx_bytes: u64, pub tx_bytes: u64,
pub rx_frames: u64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,12 +33,15 @@ pub fn run_serial_loop(
cancel_rx: &Receiver<()>, cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>, sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>, stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) { ) {
match protocol { match protocol {
SerialProtocol::TactileA => { 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<()>, cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>, sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>, stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) { ) {
let session_started_at = Instant::now(); let session_started_at = Instant::now();
let mut codec = TactileACodec::new(cols, rows); let mut codec = TactileACodec::new(cols, rows);
@@ -90,7 +96,14 @@ fn run_tactile_a_loop(
for frame in frames { for frame in frames {
if let TactileAFrame::Rep(rep) = frame { if let TactileAFrame::Rep(rep) = frame {
if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) { 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); 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<()>, cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>, sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>, stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) { ) {
let session_started_at = Instant::now(); let session_started_at = Instant::now();
let mut codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS); let mut codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS);
@@ -174,7 +188,14 @@ fn run_hand_gateway_loop(
if parse_ok { if parse_ok {
// println!("[hand-packet-values] samples={}", vals.len()); // 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); 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)] #[derive(Clone, Copy)]
pub struct AppTheme { pub struct AppTheme {
@@ -29,7 +29,7 @@ pub struct DesignMetrics {
} }
pub const ONE_DARK_PRO: AppTheme = AppTheme { pub const ONE_DARK_PRO: AppTheme = AppTheme {
bg: egui::Color32::from_rgb(30, 40, 50), bg: egui::Color32::BLACK,
panel: egui::Color32::from_rgb(22, 28, 35), panel: egui::Color32::from_rgb(22, 28, 35),
panel_strong: egui::Color32::from_rgb(34, 43, 54), panel_strong: egui::Color32::from_rgb(34, 43, 54),
panel_deep: egui::Color32::from_rgb(15, 20, 27), panel_deep: egui::Color32::from_rgb(15, 20, 27),
@@ -61,6 +61,8 @@ pub const METRICS: DesignMetrics = DesignMetrics {
pub mod layout { pub mod layout {
pub const TITLE_BAR_HEIGHT: f32 = 36.0; 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 CENTER_PANEL_TOP: f32 = 48.0;
pub const LEFT_X: f32 = 24.0; pub const LEFT_X: f32 = 24.0;
pub const RIGHT_X: f32 = 1328.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)) .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> { pub fn primary_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
egui::Button::new(label) egui::Button::new(label)
.fill(ONE_DARK_PRO.accent) .fill(ONE_DARK_PRO.accent)

927
src/ui.rs

File diff suppressed because it is too large Load Diff

View File

@@ -126,15 +126,7 @@ fn vs_background(@builtin(vertex_index) vertex_index: u32) -> BackgroundVertexOu
@fragment @fragment
fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f { fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {
let pixel = frag_coord.xy; return output_color(vec3f(0.0, 0.0, 0.0), 1.0);
let viewport = u.viewport.xy;
let uv = pixel / max(viewport, vec2f(1.0, 1.0));
var color = mix(vec3f(0.018, 0.019, 0.022), vec3f(0.038, 0.040, 0.046), 1.0 - uv.y);
let vignette = smoothstep(0.18, 0.92, length((uv - vec2f(0.52, 0.48)) * vec2f(viewport.x / viewport.y, 1.0)));
color *= 1.0 - vignette * 0.22;
color += vec3f(0.010, 0.010, 0.012) * (1.0 - smoothstep(0.0, 0.85, abs(uv.y - 0.50)));
return output_color(color, 1.0);
} }

254
wix/main.wxs Normal file
View File

@@ -0,0 +1,254 @@
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
The "cargo wix" subcommand provides a variety of predefined variables available
for customization of this template. The values for each variable are set at
installer creation time. The following variables are available:
TargetTriple = The rustc target triple name.
TargetEnv = The rustc target environment. This is typically either
"msvc" or "gnu" depending on the toolchain downloaded and
installed.
TargetVendor = The rustc target vendor. This is typically "pc", but Rust
does support other vendors, like "uwp".
CargoTargetBinDir = The complete path to the directory containing the
binaries (exes) to include. The default would be
"target\release\". If an explicit rustc target triple is
used, i.e. cross-compiling, then the default path would
be "target\<CARGO_TARGET>\<CARGO_PROFILE>",
where "<CARGO_TARGET>" is replaced with the "CargoTarget"
variable value and "<CARGO_PROFILE>" is replaced with the
value from the "CargoProfile" variable. This can also
be overridden manually with the "target-bin-dir" flag.
CargoTargetDir = The path to the directory for the build artifacts, i.e.
"target".
CargoProfile = The cargo profile used to build the binaries
(usually "debug" or "release").
Version = The version for the installer. The default is the
"Major.Minor.Fix" semantic versioning number of the Rust
package.
-->
<!--
Please do not remove these pre-processor If-Else blocks. These are used with
the `cargo wix` subcommand to automatically determine the installation
destination for 32-bit versus 64-bit installers. Removal of these lines will
cause installation errors.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Product
Id='*'
Name='eskin-model-player'
UpgradeCode='7CEF316B-BE23-4533-B4B2-87D06D2230B5'
Manufacturer='JOYSONQUIN'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='Desktop pressure sensor visualization and playback application.'
Manufacturer='JOYSONQUIN'
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perUser'
InstallPrivileges='limited'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='eskin-model-player Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='LocalAppDataFolder'>
<Directory Id='APPLICATIONFOLDER' Name='eskin-model-player'>
<!--
Enabling the license sidecar file in the installer is a four step process:
1. Uncomment the `Component` tag and its contents.
2. Change the value for the `Source` attribute in the `File` tag to a path
to the file that should be included as the license sidecar file. The path
can, and probably should be, relative to this file.
3. Change the value for the `Name` attribute in the `File` tag to the
desired name for the file when it is installed alongside the `bin` folder
in the installation directory. This can be omitted if the desired name is
the same as the file name.
4. Uncomment the `ComponentRef` tag with the Id attribute value of "License"
further down in this file.
-->
<!--
<Component Id='License' Guid='*'>
<File Id='LicenseFile' Name='ChangeMe' DiskId='1' Source='C:\Path\To\File' KeyPath='yes'/>
</Component>
-->
<Directory Id='Bin' Name='bin'>
<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'
Value='[Bin]'
Permanent='no'
Part='last'
Action='set'
System='no'/>
</Component>
<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'/>
</Component>
</Directory>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs all binaries and the license.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<!--
Uncomment the following `ComponentRef` tag to add the license
sidecar file to the installer.
-->
<!--<ComponentRef Id='License'/>-->
<ComponentRef Id='binary0'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<!--
Uncomment the following `Icon` and `Property` tags to change the product icon.
The product icon is the graphic that appears in the Add/Remove
Programs control panel for the application.
-->
<!--<Icon Id='ProductICO' SourceFile='wix\Product.ico'/>-->
<!--<Property Id='ARPPRODUCTICON' Value='ProductICO' />-->
<!--
Adding a URL to Add/Remove Programs control panel listing for the
application is a two step process:
1. Uncomment the following `Property` tag with the "ARPHELPLINK" Id
attribute value.
2. Change the value for `Value` attribute of the following
`Property` tag to a valid URL.
-->
<!--<Property Id='ARPHELPLINK' Value='ChangeMe'/>-->
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<!--
Enabling the EULA dialog in the installer is a three step process:
1. Comment out or remove the two `Publish` tags that follow the
`WixVariable` tag.
2. Uncomment the `<WixVariable Id='WixUILicenseRtf' Value='Path\to\Eula.rft'>` tag further down
3. Replace the `Value` attribute of the `WixVariable` tag with
the path to a RTF file that will be used as the EULA and
displayed in the license agreement dialog.
-->
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
</UI>
<!--
Enabling the EULA dialog in the installer requires uncommenting
the following `WixUILicenseRTF` tag and changing the `Value`
attribute.
-->
<!-- <WixVariable Id='WixUILicenseRtf' Value='Relative\Path\to\Eula.rtf'/> -->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom banner image across
the top of each screen. See the WiX Toolset documentation for details
about customization.
The banner BMP dimensions are 493 x 58 pixels.
-->
<!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom image to the first
dialog, or screen. See the WiX Toolset documentation for details about
customization.
The dialog BMP dimensions are 493 x 312 pixels.
-->
<!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
</Product>
</Wix>