5 Commits

Author SHA1 Message Date
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
lenn
d4f160af75 Integrate hand gateway and spatial force rendering 2026-06-29 18:55:42 +08:00
20 changed files with 2959 additions and 370 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

BIN
res/hand.glb Normal file

Binary file not shown.

View File

@@ -1,5 +1,6 @@
use crate::breakout::{BreakoutGame, control_from_matrix}; use crate::breakout::{BreakoutGame, control_from_matrix};
use crate::connection::ConnectionManager; use crate::connection::ConnectionManager;
use crate::force::{ForceEstimatorState, HudSpatialForce};
use crate::recording::Recorder; use crate::recording::Recorder;
use crate::render::{ActiveMode, FingerMode, HandGatewayMode}; use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout}; use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout};
@@ -7,28 +8,28 @@ use crate::ui::SerialMode;
use crate::{ use crate::{
matrix::{MATRIX_COLS, MATRIX_ROWS}, matrix::{MATRIX_COLS, MATRIX_ROWS},
render::{ render::{
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback, BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, PressureSamples,
WgpuBackgroundCallback,
}, },
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_export_panel, draw_hand_force_panels, draw_matrix_config_panel,
panel_restore_item, draw_stats_panel, panel_restore_item,
}, },
}; };
use eframe::{egui, egui_wgpu}; use eframe::{egui, egui_wgpu};
use std::sync::Arc; use std::sync::Arc;
const DATA_LOG_EVERY_FRAMES: u64 = 30;
const SUMMARY_POINTS_PER_SERIES: usize = 42; const SUMMARY_POINTS_PER_SERIES: usize = 42;
const MIN_DISPLAY_FORCE_N: f32 = 0.1; const HAND_FORCE_PANEL_COUNT: usize = 7;
const MAX_DISPLAY_FORCE_N: f32 = 25.6; const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44];
pub struct EskinDesktopApp { pub struct EskinDesktopApp {
connect_panel: FloatingPanelState, connect_panel: FloatingPanelState,
connect_state: ConnectPanelState, connect_state: ConnectPanelState,
connection: Arc<ConnectionManager>, connection: Arc<ConnectionManager>,
pressure_matrix: PressureFrame, pressure_matrix: PressureFrame,
data_log_frame: u64, hand_pressure: PressureSamples,
scene_panel: FloatingPanelState, scene_panel: FloatingPanelState,
config_panel: FloatingPanelState, config_panel: FloatingPanelState,
config_state: ConfigPanelState, config_state: ConfigPanelState,
@@ -40,6 +41,9 @@ 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,
latest_spatial_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,
@@ -77,7 +81,7 @@ impl EskinDesktopApp {
connect_state: ConnectPanelState::default(), connect_state: ConnectPanelState::default(),
connection: Arc::new(ConnectionManager::new()), connection: Arc::new(ConnectionManager::new()),
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT], pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
data_log_frame: 0, hand_pressure: Vec::new(),
scene_panel: FloatingPanelState::new( scene_panel: FloatingPanelState::new(
[layout::LEFT_X, layout::TOP_Y], [layout::LEFT_X, layout::TOP_Y],
[layout::LEFT_TAG_X, layout::TOP_Y], [layout::LEFT_TAG_X, layout::TOP_Y],
@@ -103,6 +107,9 @@ 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(),
latest_spatial_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,
@@ -129,9 +136,40 @@ impl EskinDesktopApp {
width, width,
height, height,
pressure: self.pressure_matrix, pressure: self.pressure_matrix,
hand_pressure: self.hand_pressure.clone(),
active_mode: self.active_mode.clone(), 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) {
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);
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 draw_workspace(&mut self, ui: &mut egui::Ui) { fn draw_workspace(&mut self, ui: &mut egui::Ui) {
@@ -186,34 +224,29 @@ impl EskinDesktopApp {
sample.cols, sample.cols,
&mut self.pressure_matrix, &mut self.pressure_matrix,
); );
self.hand_pressure = normalize_pressure_samples(&sample.matrix);
self.latest_raw_matrix.clear(); self.latest_raw_matrix.clear();
self.latest_raw_matrix.extend_from_slice(&sample.matrix); self.latest_raw_matrix.extend_from_slice(&sample.matrix);
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.latest_spatial_force = self.force_estimator.analyze(&sample.matrix);
self.recorder.add_frame(&sample.matrix);
// JE-Skin summary logic: sum all cells first, then convert raw summary to force. // Keep JE-Skin's summary path separate from the optional spatial-force vector.
let raw_total = sample let raw_total = sample
.matrix .matrix
.iter() .iter()
.fold(0_u64, |sum, value| sum + *value as u64) .fold(0_u64, |sum, value| sum + *value as u64)
.min(u32::MAX as u64) as u32; .min(u32::MAX as u64) as u32;
let force = raw_to_g1(raw_total).min(MAX_DISPLAY_FORCE_N); let force = raw_to_g1(raw_total).min(25.6);
let force = if force <= MIN_DISPLAY_FORCE_N { let force = if force <= 0.1 { 0.0 } else { force };
0.0
} else {
force
};
self.signal_history.push(force); self.signal_history.push(force);
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);
} }
self.data_log_frame += 1; if self.config_state.mode == SerialMode::Hand {
if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 { update_hand_signal_histories(&mut self.hand_signal_histories, &sample.matrix);
log_pressure_sample(&sample.matrix, sample.rows, sample.cols);
} }
} }
} }
@@ -331,15 +364,34 @@ impl EskinDesktopApp {
fn draw_floating_panels(&mut self, ctx: &egui::Context) { fn draw_floating_panels(&mut self, ctx: &egui::Context) {
// draw_scene_panel(ctx, &mut self.scene_panel); // draw_scene_panel(ctx, &mut self.scene_panel);
// if self.config_state.mode == SerialMode::Hand && self.stats_panel.visible {
// self.config_panel.visible = false;
// self.export_panel.visible = false;
// self.matrix_config_panel.visible = 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,
) { ) {
self.switch_mode(next_mode); self.switch_mode(next_mode);
} }
draw_stats_panel(ctx, &mut self.stats_panel, &self.signal_history); 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( draw_export_panel(
ctx, ctx,
&mut self.export_panel, &mut self.export_panel,
@@ -382,15 +434,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;
@@ -416,9 +493,16 @@ impl EskinDesktopApp {
fn switch_mode(&mut self, next: SerialMode) { fn switch_mode(&mut self, next: SerialMode) {
self.connect_state.mode = next; self.connect_state.mode = next;
self.config_state.mode = next; self.config_state.mode = next;
self.config_state.baud_rate = next.baud_rate();
self.connection.disconnect(); self.connection.disconnect();
self.pressure_matrix.fill([0.0, 0.0]); self.pressure_matrix.fill([0.0, 0.0]);
self.data_log_frame = 0; self.hand_pressure.clear();
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 { self.active_mode = match next {
SerialMode::Finger => ActiveMode::Finger(FingerMode { SerialMode::Finger => ActiveMode::Finger(FingerMode {
@@ -432,21 +516,28 @@ impl EskinDesktopApp {
} }
} }
fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) { fn update_hand_signal_histories(
let max = raw.iter().copied().max().unwrap_or(0); histories: &mut [Vec<f32>; HAND_FORCE_PANEL_COUNT],
let sum: u64 = raw.iter().map(|value| *value as u64).sum(); raw_values: &[u32],
let non_zero = raw.iter().filter(|value| **value != 0).count(); ) {
let preview = raw 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() .iter()
.take(12) .fold(0_u64, |sum, value| sum + *value as u64)
.map(u32::to_string) .min(u32::MAX as u64) as u32;
.collect::<Vec<_>>() let force = raw_to_g1(raw_total).min(25.6);
.join(", "); let force = if force <= 0.1 { 0.0 } else { force };
println!( history.push(force);
"[pressure] {rows}x{cols} cells={} non_zero={non_zero} max={max} sum={sum} first=[{preview}]", if history.len() > SUMMARY_POINTS_PER_SERIES {
raw.len() history.remove(0);
); }
offset += sample_count;
}
} }
fn raw_to_g1(raw: u32) -> f32 { fn raw_to_g1(raw: u32) -> f32 {
@@ -465,7 +556,6 @@ fn raw_to_g1(raw: u32) -> f32 {
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0; return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
} }
// Keep the same lookup behavior as JE-Skin so both apps report the same force.
let mut left = 0usize; let mut left = 0usize;
let mut right = RAW.len() - 1; let mut right = RAW.len() - 1;
while left + 1 < right { while left + 1 < right {
@@ -534,9 +624,6 @@ fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
} }
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) { fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;
normalized.fill([0.0, 0.0]); normalized.fill([0.0, 0.0]);
let src_cols = cols.max(1); let src_cols = cols.max(1);
@@ -548,17 +635,29 @@ fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut
let src_index = (row * src_cols + col) as usize; let src_index = (row * src_cols + col) as usize;
let dst_index = (row * MATRIX_COLS + col) as usize; let dst_index = (row * MATRIX_COLS + col) as usize;
if let Some(value) = raw.get(src_index) { if let Some(value) = raw.get(src_index) {
let raw_value = *value as f32; normalized[dst_index] = normalize_pressure_value(*value);
}
}
}
}
fn normalize_pressure_samples(raw: &[u32]) -> PressureSamples {
raw.iter().copied().map(normalize_pressure_value).collect()
}
fn normalize_pressure_value(value: u32) -> [f32; 2] {
const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;
let raw_value = value as f32;
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0); let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0);
let display_value = if raw_value <= RANGE_MIN + 4.0 { let display_value = if raw_value <= RANGE_MIN + 4.0 {
0.0 0.0
} else { } else {
raw_value.round().min(9999.0) raw_value.round().min(9999.0)
}; };
normalized[dst_index] = [mapped, display_value];
} [mapped, display_value]
}
}
} }
impl eframe::App for EskinDesktopApp { impl eframe::App for EskinDesktopApp {

View File

@@ -4,7 +4,10 @@ use std::time::Duration;
use crossbeam_channel::{self, Receiver, Sender, TryRecvError}; use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
use crate::serial_core::serial::{SerialIoStats, SerialPortReadWrite, run_serial_loop}; use crate::recording::Recorder;
use crate::serial_core::serial::{
SerialIoStats, SerialPortReadWrite, SerialProtocol, run_serial_loop,
};
/// Connection state visible to the UI. /// Connection state visible to the UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -28,6 +31,8 @@ struct Session {
handle: JoinHandle<()>, handle: JoinHandle<()>,
sample_rx: Receiver<Vec<i32>>, sample_rx: Receiver<Vec<i32>>,
stats_rx: Receiver<SerialIoStats>, stats_rx: Receiver<SerialIoStats>,
rows: u32,
cols: u32,
} }
/// Thread-safe connection manager that the UI and renderer can share. /// Thread-safe connection manager that the UI and renderer can share.
@@ -72,7 +77,15 @@ impl ConnectionManager {
} }
/// Connect to the given serial port and start streaming in a background thread. /// Connect to the given serial port and start streaming in a background thread.
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) { pub fn connect(
&self,
port_name: &str,
rows: u32,
cols: u32,
baud_rate: u32,
protocol: SerialProtocol,
recorder: Recorder,
) {
self.disconnect(); self.disconnect();
self.set_state(ConnectionState::Connecting); self.set_state(ConnectionState::Connecting);
@@ -90,11 +103,14 @@ impl ConnectionManager {
&port, &port,
rows, rows,
cols, cols,
baud_rate,
protocol,
&state, &state,
&cancel_rx, &cancel_rx,
&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}");
@@ -107,6 +123,8 @@ impl ConnectionManager {
handle, handle,
sample_rx, sample_rx,
stats_rx, stats_rx,
rows,
cols,
}); });
} }
@@ -135,10 +153,12 @@ impl ConnectionManager {
loop { loop {
match session.sample_rx.try_recv() { match session.sample_rx.try_recv() {
Ok(vals) => { Ok(vals) => {
let rows = 12u32;
let cols = 7u32;
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect(); let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
last = Some(PressureSample { matrix, rows, cols }); last = Some(PressureSample {
matrix,
rows: session.rows,
cols: session.cols,
});
} }
Err(TryRecvError::Empty) => break, Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break, Err(TryRecvError::Disconnected) => break,
@@ -163,14 +183,17 @@ fn run_device_loop(
port_name: &str, port_name: &str,
rows: u32, rows: u32,
cols: u32, cols: u32,
baud_rate: u32,
protocol: SerialProtocol,
state: &Arc<Mutex<ConnectionState>>, state: &Arc<Mutex<ConnectionState>>,
cancel_rx: &Receiver<()>, cancel_rx: &Receiver<()>,
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, 921_600) 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;
@@ -182,9 +205,11 @@ fn run_device_loop(
&mut rw, &mut rw,
rows as usize, rows as usize,
cols as usize, cols as usize,
protocol,
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() {

49
src/force.rs Normal file
View File

@@ -0,0 +1,49 @@
use crate::serial_core::multi_dim_force::PztProcessor;
const FINGER_SAMPLE_COUNT: usize = 84;
const MIN_TANGENTIAL_MAGNITUDE: f32 = 0.02;
#[derive(Debug, Clone, Copy)]
pub struct HudSpatialForce {
pub angle_deg: f32,
pub magnitude: f32,
}
pub struct ForceEstimatorState {
pzt_processor: PztProcessor,
}
impl ForceEstimatorState {
pub fn new() -> Self {
Self {
pzt_processor: PztProcessor::new(),
}
}
pub fn reset(&mut self) {
self.pzt_processor.reset_baseline();
}
pub fn analyze(&mut self, values: &[u32]) -> Option<HudSpatialForce> {
if values.len() != FINGER_SAMPLE_COUNT {
return None;
}
let pzt_values = values.iter().map(|value| *value as f32).collect::<Vec<_>>();
self.pzt_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,
})
}
}
impl Default for ForceEstimatorState {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,6 +1,9 @@
#![allow(dead_code)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app; mod app;
mod breakout; mod breakout;
mod connection; mod connection;
mod force;
mod matrix; mod matrix;
mod model; mod model;
mod recording; mod recording;

View File

@@ -13,11 +13,13 @@ use std::ops::Range;
pub const PRESSURE_CELL_COUNT: usize = pub const PRESSURE_CELL_COUNT: usize =
(crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize; (crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize;
pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT]; pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT];
pub type PressureSamples = Vec<[f32; 2]>;
pub struct WgpuBackgroundCallback { pub struct WgpuBackgroundCallback {
pub width: f32, pub width: f32,
pub height: f32, pub height: f32,
pub pressure: PressureFrame, pub pressure: PressureFrame,
pub hand_pressure: PressureSamples,
pub active_mode: ActiveMode, pub active_mode: ActiveMode,
} }
@@ -85,6 +87,10 @@ const HAND_PALM_CHIPS: [HandPalmChip; 2] = [
}, },
]; ];
const HAND_FINGER_SENSOR_CELLS: usize = 12 * 7;
const HAND_PALM_HORIZONTAL_OFFSET: usize = HAND_FINGER_SENSOR_CELLS * 5;
const HAND_PALM_VERTICAL_OFFSET: usize = HAND_PALM_HORIZONTAL_OFFSET + 5 * 14;
// Each entry pins one miniature matrix to a fingertip in hand.png. // Each entry pins one miniature matrix to a fingertip in hand.png.
// Coordinates are authored in source-image pixels so they are easy to tune by eye. // Coordinates are authored in source-image pixels so they are easy to tune by eye.
// size_px keeps the same 7:12 aspect as the Finger-mode 7 columns x 12 rows matrix. // size_px keeps the same 7:12 aspect as the Finger-mode 7 columns x 12 rows matrix.
@@ -114,7 +120,13 @@ impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
resources: &mut egui_wgpu::CallbackResources, resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> { ) -> Vec<wgpu::CommandBuffer> {
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap(); let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
resources.prepare(queue, self.width, self.height, &self.pressure); resources.prepare(
queue,
self.width,
self.height,
&self.pressure,
&self.hand_pressure,
);
Vec::new() Vec::new()
} }
@@ -596,7 +608,14 @@ impl BackgroundRenderResources {
} }
} }
fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &PressureFrame) { fn prepare(
&mut self,
queue: &wgpu::Queue,
width: f32,
height: f32,
pressure: &PressureFrame,
hand_pressure: &[[f32; 2]],
) {
let aspect = width / height.max(1.0); let aspect = width / height.max(1.0);
self.uniform = MatrixUniform::new( self.uniform = MatrixUniform::new(
width, width,
@@ -628,14 +647,20 @@ impl BackgroundRenderResources {
bytemuck::cast_slice(&self.glyph_instances), bytemuck::cast_slice(&self.glyph_instances),
); );
// Hand mode uses five UV-anchored fingertip matrices over hand.png. let hand_pressure = if hand_pressure.is_empty() {
pressure.as_slice()
} else {
hand_pressure
};
// Hand mode uses UV-anchored fingertip matrices over hand.png.
// Rebuild their instance positions here so pressure colors update every frame. // Rebuild their instance positions here so pressure colors update every frame.
self.hand_dot_instances = build_hand_dot_instances( self.hand_dot_instances = build_hand_dot_instances(
self.rows, self.rows,
self.cols, self.cols,
self.hand_image_texture.width as f32, self.hand_image_texture.width as f32,
self.hand_image_texture.height as f32, self.hand_image_texture.height as f32,
pressure, hand_pressure,
); );
queue.write_buffer( queue.write_buffer(
&self.hand_dot_instance_buffer, &self.hand_dot_instance_buffer,
@@ -650,7 +675,7 @@ impl BackgroundRenderResources {
self.cols, self.cols,
self.hand_image_texture.width as f32, self.hand_image_texture.width as f32,
self.hand_image_texture.height as f32, self.hand_image_texture.height as f32,
pressure, hand_pressure,
); );
queue.write_buffer( queue.write_buffer(
&self.hand_palm_dot_instance_buffer, &self.hand_palm_dot_instance_buffer,
@@ -1158,19 +1183,22 @@ fn build_hand_dot_instances(
cols: u32, cols: u32,
image_width: f32, image_width: f32,
image_height: f32, image_height: f32,
pressure: &PressureFrame, pressure: &[[f32; 2]],
) -> Vec<GlyphInstance> { ) -> Vec<GlyphInstance> {
let mut instances = Vec::with_capacity(HAND_TIP_MATRICES.len() * rows as usize * cols as usize); let mut instances = Vec::with_capacity(HAND_TIP_MATRICES.len() * rows as usize * cols as usize);
for tip in HAND_TIP_MATRICES { for (tip_index, tip) in HAND_TIP_MATRICES.into_iter().enumerate() {
let cos = tip.angle_rad.cos(); let cos = tip.angle_rad.cos();
let sin = tip.angle_rad.sin(); let sin = tip.angle_rad.sin();
for row in 0..rows { for row in 0..rows {
for col in 0..cols { for col in 0..cols {
let index = (row * cols + col) as usize; let index = (row * cols + col) as usize;
let [normalized, display_value] = let [normalized, display_value] = sample_pressure_at(
pressure.get(index).copied().unwrap_or([0.0, 0.0]); pressure,
tip_index * HAND_FINGER_SENSOR_CELLS + index,
index,
);
// Lay out a rows x cols matrix in fingertip-local pixel space. // 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_x = (col as f32 - cols as f32 / 2.0 + 0.5) / cols as f32 * tip.size_px[0];
@@ -1195,11 +1223,11 @@ fn build_hand_dot_instances(
} }
fn build_hand_palm_dot_instances( fn build_hand_palm_dot_instances(
rows: u32, _rows: u32,
cols: u32, _cols: u32,
image_width: f32, image_width: f32,
image_height: f32, image_height: f32,
pressure: &PressureFrame, pressure: &[[f32; 2]],
) -> Vec<GlyphInstance> { ) -> Vec<GlyphInstance> {
let chip_dot_count: usize = HAND_PALM_CHIPS let chip_dot_count: usize = HAND_PALM_CHIPS
.iter() .iter()
@@ -1207,7 +1235,7 @@ fn build_hand_palm_dot_instances(
.sum(); .sum();
let mut instances = Vec::with_capacity(chip_dot_count); let mut instances = Vec::with_capacity(chip_dot_count);
for chip in HAND_PALM_CHIPS { for (chip_index, chip) in HAND_PALM_CHIPS.into_iter().enumerate() {
let cos = chip.angle_rad.cos(); let cos = chip.angle_rad.cos();
let sin = chip.angle_rad.sin(); let sin = chip.angle_rad.sin();
// Leave a bevel around the chip so the matrix reads as embedded pixels. // Leave a bevel around the chip so the matrix reads as embedded pixels.
@@ -1215,13 +1243,13 @@ fn build_hand_palm_dot_instances(
for row in 0..chip.rows { for row in 0..chip.rows {
for col in 0..chip.cols { for col in 0..chip.cols {
// Current live data is still the device pressure frame. Sample it by let index = (row * chip.cols + col) as usize;
// proportion so the palm's 5x14 and 11x4 layouts get coherent values. let offset = match chip_index {
let source_row = resample_index(row, chip.rows, rows); 0 => HAND_PALM_HORIZONTAL_OFFSET,
let source_col = resample_index(col, chip.cols, cols); _ => HAND_PALM_VERTICAL_OFFSET,
let index = (source_row * cols + source_col) as usize; };
let [normalized, display_value] = let [normalized, display_value] =
pressure.get(index).copied().unwrap_or([0.0, 0.0]); sample_pressure_at(pressure, offset + index, index);
let local_x = let local_x =
(col as f32 - chip.cols as f32 / 2.0 + 0.5) / chip.cols as f32 * active_size[0]; (col as f32 - chip.cols as f32 / 2.0 + 0.5) / chip.cols as f32 * active_size[0];
@@ -1247,13 +1275,12 @@ fn build_hand_palm_dot_instances(
instances instances
} }
fn resample_index(index: u32, source_count: u32, target_count: u32) -> u32 { fn sample_pressure_at(pressure: &[[f32; 2]], index: usize, fallback_index: usize) -> [f32; 2] {
if source_count <= 1 || target_count <= 1 { pressure
return 0; .get(index)
} .or_else(|| pressure.get(fallback_index))
.copied()
let mapped = index as f32 / (source_count - 1) as f32 * (target_count - 1) as f32; .unwrap_or([0.0, 0.0])
mapped.round().clamp(0.0, (target_count - 1) as f32) as u32
} }
fn build_glyph_instances( fn build_glyph_instances(

View File

@@ -0,0 +1,404 @@
use crate::serial_core::codec::Codec;
use crate::serial_core::error::CodecError;
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
use std::time::Instant;
const FRAME_HEADER: [u8; 2] = [0x55, 0xAA];
const RESPONSE_DATA_CMD: u8 = 0x81;
const RESPONSE_DATA_LEGACY_FIXED_LENGTH: usize = 16;
const RESPONSE_DATA_LEGACY_PREFIX_LENGTH: usize = 19;
const RESPONSE_DATA_TEMP_FIXED_LENGTH: usize = 20;
const RESPONSE_DATA_TEMP_PREFIX_LENGTH: usize = 23;
const MIN_FRAME_LENGTH: usize = 7;
const DEFAULT_MAX_FRAME_LENGTH: usize = 64 * 1024;
#[derive(Debug, Clone, Copy)]
struct HandGatewayResponseLayout {
fixed_length: usize,
prefix_length: usize,
timestamp_len: usize,
config_offset: usize,
valid_config_offset: usize,
block_count_offset: usize,
}
const TEMP_RESPONSE_LAYOUT: HandGatewayResponseLayout = HandGatewayResponseLayout {
fixed_length: RESPONSE_DATA_TEMP_FIXED_LENGTH,
prefix_length: RESPONSE_DATA_TEMP_PREFIX_LENGTH,
timestamp_len: 8,
config_offset: 14,
valid_config_offset: 18,
block_count_offset: 22,
};
const LEGACY_RESPONSE_LAYOUT: HandGatewayResponseLayout = HandGatewayResponseLayout {
fixed_length: RESPONSE_DATA_LEGACY_FIXED_LENGTH,
prefix_length: RESPONSE_DATA_LEGACY_PREFIX_LENGTH,
timestamp_len: 4,
config_offset: 10,
valid_config_offset: 14,
block_count_offset: 18,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayNodeConfig {
pub config_mask: u32,
pub sample_count: usize,
}
impl HandGatewayNodeConfig {
pub const fn new(config_mask: u32, sample_count: usize) -> Self {
Self {
config_mask,
sample_count,
}
}
fn payload_len(&self, bytes_per_sample: usize) -> Option<usize> {
self.sample_count.checked_mul(bytes_per_sample)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayConfig {
pub protocol_version: u8,
pub bytes_per_sample: usize,
pub max_frame_length: usize,
pub nodes: Vec<HandGatewayNodeConfig>,
}
impl HandGatewayConfig {
pub fn new(nodes: Vec<HandGatewayNodeConfig>) -> Self {
Self {
protocol_version: 0x01,
bytes_per_sample: 2,
max_frame_length: DEFAULT_MAX_FRAME_LENGTH,
nodes,
}
}
fn validate(&self) -> Result<(), CodecError> {
if self.bytes_per_sample == 0 || self.max_frame_length < MIN_FRAME_LENGTH {
return Err(CodecError::InvalidLength);
}
let mut used_masks = 0u32;
for node in &self.nodes {
if node.config_mask == 0
|| node.config_mask.count_ones() != 1
|| used_masks & node.config_mask != 0
|| node.payload_len(self.bytes_per_sample).is_none()
{
return Err(CodecError::InvalidLength);
}
used_masks |= node.config_mask;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayFrameNode {
pub config_mask: u32,
pub valid: bool,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayDataRepFrame {
pub timestamp: u64,
pub config: u32,
pub valid_config: u32,
pub block_count: u8,
pub raw: Vec<u8>,
pub nodes: Vec<HandGatewayFrameNode>,
pub dts_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandGatewayFrame {
DataRep(HandGatewayDataRepFrame),
}
pub struct HandGatewayCodec {
buffer: Vec<u8>,
config: HandGatewayConfig,
}
impl HandGatewayCodec {
pub fn new(node_sample_counts: &[u16]) -> Self {
let nodes = node_sample_counts
.iter()
.enumerate()
.map(|(index, &sample_count)| {
HandGatewayNodeConfig::new(
1u32.checked_shl(index as u32).unwrap_or(0),
sample_count as usize,
)
})
.collect();
Self {
buffer: Vec::new(),
config: HandGatewayConfig::new(nodes),
}
}
pub fn parse_node_payload(data: &[u8]) -> Result<Vec<u16>, CodecError> {
if data.len() % 2 != 0 {
return Err(CodecError::InvalidLength);
}
Ok(data
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect())
}
fn retain_possible_header_prefix(&mut self) {
if self.buffer.last() == Some(&FRAME_HEADER[0]) {
self.buffer.drain(..self.buffer.len() - 1);
} else {
self.buffer.clear();
}
}
fn parse_data_response_with_layout(
&self,
frame: &[u8],
length: u16,
session_started_at: Instant,
layout: HandGatewayResponseLayout,
) -> Result<HandGatewayFrame, CodecError> {
let payload_len = usize::from(length)
.checked_sub(layout.fixed_length)
.ok_or(CodecError::InvalidLength)?;
let payload_end = layout
.prefix_length
.checked_add(payload_len)
.ok_or(CodecError::PayloadTooLarge)?;
if payload_end + 1 != frame.len() {
return Err(CodecError::InvalidLength);
}
let version = frame[5];
if version != self.config.protocol_version {
return Err(CodecError::InvalidFrameType);
}
let timestamp = match layout.timestamp_len {
4 => u32::from_le_bytes(frame[6..10].try_into().unwrap()) as u64,
8 => u64::from_le_bytes(frame[6..14].try_into().unwrap()),
_ => return Err(CodecError::InvalidLength),
};
let config = u32::from_le_bytes(
frame[layout.config_offset..layout.config_offset + 4]
.try_into()
.unwrap(),
);
let valid_config = u32::from_le_bytes(
frame[layout.valid_config_offset..layout.valid_config_offset + 4]
.try_into()
.unwrap(),
);
let block_count = frame[layout.block_count_offset];
let active_nodes = self
.config
.nodes
.iter()
.filter(|node| config & node.config_mask != 0)
.collect::<Vec<_>>();
if active_nodes.len() != usize::from(block_count) {
return Err(CodecError::InvalidLength);
}
let expected_payload_len = active_nodes.iter().try_fold(0usize, |total, node| {
let node_len = node
.payload_len(self.config.bytes_per_sample)
.ok_or(CodecError::PayloadTooLarge)?;
total
.checked_add(node_len)
.ok_or(CodecError::PayloadTooLarge)
})?;
if expected_payload_len != payload_len {
return Err(CodecError::InvalidLength);
}
let mut cursor = layout.prefix_length;
let mut nodes = Vec::with_capacity(active_nodes.len());
for node in active_nodes {
let node_len = node
.payload_len(self.config.bytes_per_sample)
.ok_or(CodecError::PayloadTooLarge)?;
let next = cursor + node_len;
nodes.push(HandGatewayFrameNode {
config_mask: node.config_mask,
valid: valid_config & node.config_mask != 0,
payload: frame[cursor..next].to_vec(),
});
cursor = next;
}
Ok(HandGatewayFrame::DataRep(HandGatewayDataRepFrame {
timestamp,
config,
valid_config,
block_count,
raw: frame.to_vec(),
nodes,
dts_ms: elapsed_millis(session_started_at),
}))
}
fn parse_data_response(
&self,
frame: &[u8],
length: u16,
session_started_at: Instant,
) -> Result<HandGatewayFrame, CodecError> {
self.parse_data_response_with_layout(
frame,
length,
session_started_at,
TEMP_RESPONSE_LAYOUT,
)
.or_else(|_| {
self.parse_data_response_with_layout(
frame,
length,
session_started_at,
LEGACY_RESPONSE_LAYOUT,
)
})
}
}
impl Codec<HandGatewayFrame> for HandGatewayCodec {
fn decode(
&mut self,
input: &[u8],
session_started_at: Instant,
) -> Result<Vec<HandGatewayFrame>, CodecError> {
self.config.validate()?;
self.buffer.extend_from_slice(input);
let mut frames = Vec::new();
loop {
let Some(header_pos) = self
.buffer
.windows(FRAME_HEADER.len())
.position(|window| window == FRAME_HEADER)
else {
self.retain_possible_header_prefix();
break;
};
if header_pos > 0 {
self.buffer.drain(..header_pos);
}
if self.buffer.len() < 4 {
break;
}
let length = u16::from_le_bytes([self.buffer[2], self.buffer[3]]);
let frame_len = usize::from(length)
.checked_add(4)
.ok_or(CodecError::PayloadTooLarge)?;
if frame_len < MIN_FRAME_LENGTH || frame_len > self.config.max_frame_length {
log::debug!("invalid hand gateway frame length: {frame_len}");
self.buffer.drain(..1);
continue;
}
if self.buffer.len() < frame_len {
break;
}
let expected_checksum = calc_crc8_itu(&self.buffer[..frame_len - 1]);
let received_checksum = self.buffer[frame_len - 1];
if expected_checksum != received_checksum {
log::debug!(
"hand gateway checksum mismatch: expected {expected_checksum:02X}, got {received_checksum:02X}"
);
self.buffer.drain(..1);
continue;
}
if self.buffer[4] == RESPONSE_DATA_CMD {
match self.parse_data_response(
&self.buffer[..frame_len],
length,
session_started_at,
) {
Ok(frame) => frames.push(frame),
Err(error) => log::debug!("invalid hand gateway data response: {error}"),
}
}
self.buffer.drain(..frame_len);
}
Ok(frames)
}
fn encode(&self, frame: &HandGatewayFrame) -> Result<Vec<u8>, CodecError> {
let _ = frame;
Err(CodecError::InvalidFrameType)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn data_response(config: u32, valid_config: u32, payload: &[u8]) -> Vec<u8> {
let block_count = config.count_ones() as u8;
let length = (RESPONSE_DATA_LEGACY_FIXED_LENGTH + payload.len()) as u16;
let mut frame = Vec::new();
frame.extend_from_slice(&FRAME_HEADER);
frame.extend_from_slice(&length.to_le_bytes());
frame.push(RESPONSE_DATA_CMD);
frame.push(0x01);
frame.extend_from_slice(&0x0102_0304u32.to_le_bytes());
frame.extend_from_slice(&config.to_le_bytes());
frame.extend_from_slice(&valid_config.to_le_bytes());
frame.push(block_count);
frame.extend_from_slice(payload);
let checksum = calc_crc8_itu(&frame);
frame.push(checksum);
frame
}
#[test]
fn decodes_data_response_frame() {
let payload = [0x10, 0x00, 0x34, 0x12, 0x08, 0x00];
let bytes = data_response(0b11, 0b01, &payload);
let frames = HandGatewayCodec::new(&[2, 1])
.decode(&bytes, Instant::now())
.unwrap();
assert_eq!(frames.len(), 1);
let HandGatewayFrame::DataRep(frame) = &frames[0];
assert_eq!(frame.timestamp, 0x0102_0304);
assert_eq!(frame.config, 0b11);
assert_eq!(frame.valid_config, 0b01);
assert_eq!(frame.block_count, 2);
assert_eq!(frame.raw, bytes);
assert_eq!(frame.nodes.len(), 2);
assert_eq!(
HandGatewayCodec::parse_node_payload(&frame.nodes[0].payload).unwrap(),
vec![16, 0x1234]
);
assert_eq!(
HandGatewayCodec::parse_node_payload(&frame.nodes[1].payload).unwrap(),
vec![8]
);
}
}

View File

@@ -1 +1,2 @@
pub mod hand_gateway;
pub mod tactile_a; pub mod tactile_a;

View File

@@ -2,5 +2,6 @@ pub mod codec;
pub mod codecs; pub mod codecs;
pub mod error; pub mod error;
pub mod frame; pub mod frame;
pub mod multi_dim_force;
pub mod serial; pub mod serial;
pub mod utils; pub mod utils;

View File

@@ -0,0 +1,234 @@
const SENSOR_ROWS: usize = 12;
const SENSOR_COLS: usize = 7;
const SENSOR_COUNT: usize = SENSOR_ROWS * SENSOR_COLS;
const TOTAL_PRESSURE_LOW_THRESHOLD: f32 = 500.0;
const COP_STABILITY_FRAMES_REQUIRED: usize = 15;
const POST_INIT_WINDOW_CNT: usize = 100;
const POST_INIT_STABLE_CNT: usize = 50;
const POST_INIT_STABLE_THRESH: f32 = 0.1;
#[derive(Debug, Clone, Copy)]
pub struct PztSpatialAnalysis {
pub angle_deg: f32,
pub magnitude: f32,
pub planar_x: f32,
pub planar_y: f32,
}
pub struct PztProcessor {
first_frame: Option<Vec<f32>>,
first_contact_cop_x: Option<f32>,
first_contact_cop_y: Option<f32>,
contact_initialized: bool,
total_pressure_low_counter: usize,
cop_init_x_buf: Vec<f32>,
cop_init_y_buf: Vec<f32>,
post_init_frame_cnt: usize,
post_stable_cnt: usize,
post_refined_flag: bool,
post_cand_x: Option<f32>,
post_cand_y: Option<f32>,
}
impl PztProcessor {
pub fn new() -> Self {
Self {
first_frame: None,
first_contact_cop_x: None,
first_contact_cop_y: None,
contact_initialized: false,
total_pressure_low_counter: 0,
cop_init_x_buf: Vec::with_capacity(COP_STABILITY_FRAMES_REQUIRED),
cop_init_y_buf: Vec::with_capacity(COP_STABILITY_FRAMES_REQUIRED),
post_init_frame_cnt: 0,
post_stable_cnt: 0,
post_refined_flag: false,
post_cand_x: None,
post_cand_y: None,
}
}
fn subtract_baseline(&mut self, current_frame: &[f32]) -> Vec<f32> {
if self.first_frame.is_none() {
self.first_frame = Some(current_frame.to_vec());
}
let baseline = self.first_frame.as_ref().unwrap();
current_frame
.iter()
.zip(baseline.iter())
.map(|(current, baseline)| (current - baseline).max(0.0))
.collect()
}
fn reset_cop_state(&mut self) {
self.first_contact_cop_x = None;
self.first_contact_cop_y = None;
self.contact_initialized = false;
self.total_pressure_low_counter = 0;
self.cop_init_x_buf.clear();
self.cop_init_y_buf.clear();
self.post_init_frame_cnt = 0;
self.post_stable_cnt = 0;
self.post_refined_flag = false;
self.post_cand_x = None;
self.post_cand_y = None;
}
fn compute_median(sorted: &[f32]) -> f32 {
let n = sorted.len();
if n == 0 {
return 0.0;
}
if n % 2 == 0 {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
} else {
sorted[n / 2]
}
}
fn compute_pressure_direction(&mut self, frame: &[f32]) -> (f32, f32) {
let total_pressure = frame.iter().sum::<f32>();
if total_pressure < TOTAL_PRESSURE_LOW_THRESHOLD {
self.total_pressure_low_counter += 1;
} else {
self.total_pressure_low_counter = 0;
}
if self.total_pressure_low_counter >= COP_STABILITY_FRAMES_REQUIRED {
self.reset_cop_state();
return (0.0, 0.0);
}
if total_pressure == 0.0 {
return (0.0, 0.0);
}
let mut sum_x = 0.0;
let mut sum_y = 0.0;
for row in 0..SENSOR_ROWS {
for col in 0..SENSOR_COLS {
let value = frame[row * SENSOR_COLS + col];
sum_x += value * col as f32;
sum_y += value * row as f32;
}
}
let cop_x = sum_x / total_pressure;
let cop_y = sum_y / total_pressure;
if !self.contact_initialized {
self.cop_init_x_buf.push(cop_x);
self.cop_init_y_buf.push(cop_y);
if self.cop_init_x_buf.len() >= COP_STABILITY_FRAMES_REQUIRED {
let mut xs = self.cop_init_x_buf.clone();
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mut ys = self.cop_init_y_buf.clone();
ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
self.first_contact_cop_x = Some(Self::compute_median(&xs));
self.first_contact_cop_y = Some(Self::compute_median(&ys));
self.contact_initialized = true;
self.cop_init_x_buf.clear();
self.cop_init_y_buf.clear();
}
return (0.0, 0.0);
}
self.post_init_frame_cnt += 1;
if !self.post_refined_flag && self.post_init_frame_cnt <= POST_INIT_WINDOW_CNT {
if let (Some(cx), Some(cy)) = (self.post_cand_x, self.post_cand_y) {
let dist = ((cop_x - cx).powi(2) + (cop_y - cy).powi(2)).sqrt();
if dist <= POST_INIT_STABLE_THRESH {
self.post_stable_cnt += 1;
} else {
self.post_cand_x = Some(cop_x);
self.post_cand_y = Some(cop_y);
self.post_stable_cnt = 1;
}
} else {
self.post_cand_x = Some(cop_x);
self.post_cand_y = Some(cop_y);
self.post_stable_cnt = 1;
}
if self.post_stable_cnt >= POST_INIT_STABLE_CNT {
self.first_contact_cop_x = self.post_cand_x;
self.first_contact_cop_y = self.post_cand_y;
self.post_refined_flag = true;
}
} else {
self.post_refined_flag = true;
}
let base_x = self.first_contact_cop_x.unwrap_or(cop_x);
let base_y = self.first_contact_cop_y.unwrap_or(cop_y);
let delta_x = cop_x - base_x;
let delta_y = base_y - cop_y;
(delta_x, delta_y)
}
fn compute_vector_angle(x: f32, y: f32) -> (f32, f32) {
let epsilon = 1e-8f32;
let magnitude = (x * x + y * y).sqrt();
let mut angle = y.atan2(x + epsilon).to_degrees();
if angle < 0.0 {
angle += 360.0;
}
(angle, magnitude)
}
pub fn get_pzt_analysis(
&mut self,
adc_data: &[f32],
) -> Result<PztSpatialAnalysis, &'static str> {
if adc_data.len() != SENSOR_COUNT {
return Err("ADC data length must be 84");
}
let baseline = self.subtract_baseline(adc_data);
let (dx, dy) = self.compute_pressure_direction(&baseline);
let planar_x = dx;
let planar_y = -dy;
let (angle_deg, magnitude) = Self::compute_vector_angle(planar_x, planar_y);
Ok(PztSpatialAnalysis {
angle_deg,
magnitude,
planar_x,
planar_y,
})
}
pub fn get_pzt_angle(&mut self, adc_data: &[f32]) -> Result<f32, &'static str> {
Ok(self.get_pzt_analysis(adc_data)?.angle_deg)
}
pub fn reset_baseline(&mut self) {
self.first_frame = None;
self.reset_cop_state();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_frame_returns_zero() {
let mut processor = PztProcessor::new();
let frame = [0.0f32; SENSOR_COUNT];
let analysis = processor.get_pzt_analysis(&frame).unwrap();
assert_eq!(analysis.magnitude, 0.0);
assert_eq!(analysis.angle_deg, 0.0);
}
}

View File

@@ -1,11 +1,14 @@
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::tactile_a::TactileACodec; use crate::serial_core::codecs::tactile_a::TactileACodec;
use crate::serial_core::frame::TactileAFrame; use crate::serial_core::frame::TactileAFrame;
use crossbeam_channel::{Receiver, Sender}; 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];
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SerialIoStats { pub struct SerialIoStats {
@@ -13,15 +16,42 @@ pub struct SerialIoStats {
pub tx_bytes: u64, pub tx_bytes: u64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SerialProtocol {
TactileA,
HandGateway,
}
/// Runs the serial polling loop on the calling (background) thread. /// Runs the serial polling loop on the calling (background) thread.
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel. /// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
pub fn run_serial_loop( pub fn run_serial_loop(
port: &mut dyn ReadWrite, port: &mut dyn ReadWrite,
rows: usize, rows: usize,
cols: usize, cols: usize,
protocol: SerialProtocol,
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 {
SerialProtocol::TactileA => {
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)
}
}
}
fn run_tactile_a_loop(
port: &mut dyn ReadWrite,
rows: usize,
cols: usize,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
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);
@@ -65,6 +95,11 @@ pub fn run_serial_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);
} }
} }
@@ -86,6 +121,103 @@ pub fn run_serial_loop(
} }
} }
fn run_hand_gateway_loop(
port: &mut dyn ReadWrite,
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);
let mut buffer = [0u8; 1024];
let poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
let mut io_stats = SerialIoStats::default();
loop {
if cancel_rx.try_recv().is_ok() {
break;
}
let deadline = Instant::now() + poll_interval;
loop {
if Instant::now() >= deadline {
break;
}
match port.read(&mut buffer) {
Ok(n) if n > 0 => {
io_stats.rx_bytes += n as u64;
publish_stats(stats_tx, io_stats);
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
for frame in frames {
let HandGatewayFrame::DataRep(rep) = frame;
// println!(
// "[hand-packet-raw] bytes={} timestamp_us={} config=0x{:08X} valid_config=0x{:08X} block_count={} payload_bytes={} raw={}",
// rep.raw.len(),
// rep.timestamp,
// rep.config,
// rep.valid_config,
// rep.block_count,
// rep.nodes
// .iter()
// .map(|node| node.payload.len())
// .sum::<usize>(),
// format_hex_bytes(&rep.raw)
// );
let mut vals = Vec::new();
let mut parse_ok = true;
for node in &rep.nodes {
match HandGatewayCodec::parse_node_payload(&node.payload) {
Ok(node_values) => {
vals.extend(node_values.into_iter().map(|raw| {
let raw = raw as i32;
if raw < 15 { 0 } else { raw }
}));
}
Err(err) => {
parse_ok = false;
eprintln!("[hand-packet-values] parse error: {err}");
break;
}
}
}
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);
}
}
}
}
Ok(_) => {
std::thread::sleep(Duration::from_millis(1));
}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
continue;
}
Err(e) => {
eprintln!("[serial] hand gateway read error: {e}");
return;
}
}
}
}
}
fn format_hex_bytes(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02X}"))
.collect::<Vec<_>>()
.join(" ")
}
fn publish_stats(stats_tx: Option<&Sender<SerialIoStats>>, stats: SerialIoStats) { fn publish_stats(stats_tx: Option<&Sender<SerialIoStats>>, stats: SerialIoStats) {
if let Some(tx) = stats_tx { if let Some(tx) = stats_tx {
let _ = tx.try_send(stats); let _ = tx.try_send(stats);

View File

@@ -26,13 +26,17 @@ pub fn calc_crc8_itu(c: &[u8]) -> u8 {
crc8_itu_alg.checksum(c) crc8_itu_alg.checksum(c)
} }
pub fn calc_crc8_itu_xor55(c: &[u8]) -> u8 {
calc_crc8_itu(c) ^ 0x55
}
pub fn elapsed_millis(start_at: Instant) -> u64 { pub fn elapsed_millis(start_at: Instant) -> u64 {
start_at.elapsed().as_millis() as u64 start_at.elapsed().as_millis() as u64
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::serial_core::utils::{calc_crc8_itu, calc_crc8_smbus}; use crate::serial_core::utils::{calc_crc8_itu, calc_crc8_itu_xor55, calc_crc8_smbus};
#[test] #[test]
fn test_crc8_itu() { fn test_crc8_itu() {
@@ -51,4 +55,10 @@ mod test {
let checksum = calc_crc8_smbus(req_vec.as_slice()); let checksum = calc_crc8_smbus(req_vec.as_slice());
assert_eq!(checksum, 0x2F); assert_eq!(checksum, 0x2F);
} }
#[test]
fn test_crc8_itu_xor55() {
let req_vec = vec![0x55, 0xAA, 0x07, 0x00, 0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF];
assert_eq!(calc_crc8_itu_xor55(req_vec.as_slice()), 0xFD);
}
} }

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),
@@ -213,6 +213,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)

546
src/ui.rs
View File

@@ -2,11 +2,12 @@ use eframe::egui;
use crate::{ use crate::{
connection::{ConnectionManager, ConnectionState}, connection::{ConnectionManager, ConnectionState},
force::HudSpatialForce,
recording::Recorder, recording::Recorder,
serial_core::serial::SerialIoStats, serial_core::serial::{SerialIoStats, SerialProtocol},
style::{ style::{
self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO, self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO,
dim_text, group_frame, layout, panel_frame, tag_button, dim_text, group_frame, layout, panel_frame, rich_tag_button, tag_button,
}, },
utils::serial_enum, utils::serial_enum,
}; };
@@ -53,6 +54,22 @@ pub enum SerialMode {
Hand, Hand,
} }
impl SerialMode {
pub fn baud_rate(self) -> u32 {
match self {
SerialMode::Finger => 921_600,
SerialMode::Hand => 1_152_000,
}
}
pub fn protocol(self) -> SerialProtocol {
match self {
SerialMode::Finger => SerialProtocol::TactileA,
SerialMode::Hand => SerialProtocol::HandGateway,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum Parity { pub enum Parity {
None, None,
@@ -121,8 +138,60 @@ impl Default for ConnectPanelState {
} }
} }
const PANEL_VIEWPORT_MARGIN: f32 = 12.0;
fn viewport_panel_rect(ctx: &egui::Context) -> egui::Rect {
ctx.content_rect().shrink(PANEL_VIEWPORT_MARGIN)
}
fn responsive_panel_width(ctx: &egui::Context, preferred: f32, min_width: f32) -> f32 {
let screen = ctx.content_rect();
let available = (screen.width() - PANEL_VIEWPORT_MARGIN * 2.0).max(240.0);
let side_cap = (screen.width() * 0.44).max(min_width.min(available));
preferred
.min(side_cap)
.min(available)
.max(min_width.min(available))
}
fn responsive_panel_height(ctx: &egui::Context, preferred: f32, min_height: f32) -> f32 {
let screen = ctx.content_rect();
let available = (screen.height() - layout::TITLE_BAR_HEIGHT - PANEL_VIEWPORT_MARGIN * 2.0)
.max(min_height.min(screen.height()));
let height_cap = (screen.height() * 0.72).max(min_height.min(available));
preferred
.min(height_cap)
.min(available)
.max(min_height.min(available))
}
fn responsive_panel_pos(
ctx: &egui::Context,
preferred: egui::Pos2,
panel_width: f32,
) -> egui::Pos2 {
let rect = viewport_panel_rect(ctx);
let min_y = rect.top() + layout::TITLE_BAR_HEIGHT + PANEL_VIEWPORT_MARGIN;
let max_x = (rect.right() - panel_width).max(rect.left());
let preferred_right_side = preferred.x > rect.center().x;
let x = if preferred_right_side {
max_x
} else {
preferred.x.clamp(rect.left(), max_x)
};
let y = preferred.y.clamp(min_y, rect.bottom());
egui::pos2(x, y)
}
fn narrow_panel(ui: &egui::Ui) -> bool {
ui.available_width() < 420.0
}
pub fn draw_scene_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) { pub fn draw_scene_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) {
draw_floating_panel(ctx, panel, "场景", "scene_panel", |ui| { draw_floating_panel(ctx, panel, "场景", "scene_panel", 320.0, 260.0, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.colored_label(dim_text(), "视图"); ui.colored_label(dim_text(), "视图");
let _ = ui.selectable_label(true, ""); let _ = ui.selectable_label(true, "");
@@ -179,7 +248,7 @@ pub fn draw_connect_panel(
}); });
ui.add_space(METRICS.row_gap); ui.add_space(METRICS.row_gap);
draw_connect_action_row(ui, conn_state, is_connected, config, connection); draw_connect_action_row(ui, conn_state, is_connected, config, connection, recorder);
if is_connected { if is_connected {
draw_recording_toolbar(ui, recorder, export_path); draw_recording_toolbar(ui, recorder, export_path);
@@ -206,7 +275,7 @@ pub fn draw_connect_panel(
// } // }
fn draw_connect_port_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) { fn draw_connect_port_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) {
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
ui.label(style::field_label("串口")); ui.label(style::field_label("串口"));
egui::ComboBox::from_id_salt("connect_ports") egui::ComboBox::from_id_salt("connect_ports")
.width(150.0) .width(150.0)
@@ -247,7 +316,7 @@ fn draw_connect_port_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) {
} }
fn draw_connect_matrix_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) { fn draw_connect_matrix_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) {
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut config.manual, "手动矩阵"); ui.checkbox(&mut config.manual, "手动矩阵");
ui.add_enabled_ui(config.manual, |ui| { ui.add_enabled_ui(config.manual, |ui| {
@@ -272,8 +341,9 @@ fn draw_connect_action_row(
is_connected: bool, is_connected: bool,
config: &ConnectPanelState, config: &ConnectPanelState,
connection: &ConnectionManager, connection: &ConnectionManager,
recorder: &Recorder,
) { ) {
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
let status_text = match conn_state { let status_text = match conn_state {
ConnectionState::Disconnected => "未连接", ConnectionState::Disconnected => "未连接",
ConnectionState::Connecting => "连接中...", ConnectionState::Connecting => "连接中...",
@@ -305,6 +375,9 @@ fn draw_connect_action_row(
&config.selected_port, &config.selected_port,
config.rows as u32, config.rows as u32,
config.cols as u32, config.cols as u32,
config.mode.baud_rate(),
config.mode.protocol(),
recorder.clone(),
); );
} }
} }
@@ -317,17 +390,20 @@ pub fn draw_config_panel(
panel: &mut FloatingPanelState, panel: &mut FloatingPanelState,
config: &mut ConfigPanelState, config: &mut ConfigPanelState,
connection: &ConnectionManager, connection: &ConnectionManager,
recorder: &Recorder,
) -> Option<SerialMode> { ) -> Option<SerialMode> {
let mut changed_mode = None; let mut changed_mode = None;
let conn_state = connection.state(); let conn_state = connection.state();
let stats = connection.stats(); let stats = connection.stats();
let panel_width = responsive_panel_width(ctx, 560.0, 340.0);
config.connected = matches!( config.connected = matches!(
conn_state, conn_state,
ConnectionState::Connected | ConnectionState::Streaming ConnectionState::Connected | ConnectionState::Streaming
); );
draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| { draw_floating_panel(ctx, panel, "配置", "config_panel", 560.0, 340.0, |ui| {
ui.set_min_width(560.0); ui.set_min_width(panel_width);
ui.set_max_width(panel_width);
if let Some(mode) = draw_mode_row(ui, config) { if let Some(mode) = draw_mode_row(ui, config) {
changed_mode = Some(mode); changed_mode = Some(mode);
@@ -335,7 +411,7 @@ pub fn draw_config_panel(
// draw_mode_row(ui, config); // draw_mode_row(ui, config);
ui.separator(); ui.separator();
draw_connection_row(ui, config, connection, conn_state); draw_connection_row(ui, config, connection, recorder, conn_state);
ui.add_space(8.0); ui.add_space(8.0);
// Legacy serial parameter grid is intentionally kept commented for future hardware: // Legacy serial parameter grid is intentionally kept commented for future hardware:
// draw_serial_grid(ui, config); // draw_serial_grid(ui, config);
@@ -347,7 +423,7 @@ pub fn draw_config_panel(
fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<SerialMode> { fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<SerialMode> {
let mut changed_to = None; let mut changed_to = None;
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
ui.colored_label(dim_text(), "模式"); ui.colored_label(dim_text(), "模式");
ui.add_space(12.0); ui.add_space(12.0);
@@ -373,21 +449,15 @@ fn draw_connection_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
config: &mut ConfigPanelState, config: &mut ConfigPanelState,
connection: &ConnectionManager, connection: &ConnectionManager,
recorder: &Recorder,
conn_state: ConnectionState, conn_state: ConnectionState,
) { ) {
ui.horizontal(|ui| { let is_narrow = narrow_panel(ui);
ui.add( let draw_port_status = |ui: &mut egui::Ui, config: &mut ConfigPanelState| {
egui::Image::new(egui::include_image!("../static/cpu.png")) ui.horizontal_wrapped(|ui| {
.fit_to_exact_size(egui::vec2(72.0, 72.0)),
);
ui.add_space(METRICS.item_gap);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(style::field_label("端口")); ui.label(style::field_label("端口"));
egui::ComboBox::from_id_salt("config_ports") egui::ComboBox::from_id_salt("config_ports")
.width(126.0) .width(ui.available_width().min(150.0).max(104.0))
.selected_text(if config.port.is_empty() { .selected_text(if config.port.is_empty() {
"无可用串口".to_owned() "无可用串口".to_owned()
} else { } else {
@@ -415,10 +485,9 @@ fn draw_connection_row(
connection_status_color(conn_state), connection_status_color(conn_state),
connection_status_text(conn_state), connection_status_text(conn_state),
); );
}); };
ui.add_space(22.0);
let draw_connect_button = |ui: &mut egui::Ui, config: &ConfigPanelState| {
let is_connected = matches!( let is_connected = matches!(
conn_state, conn_state,
ConnectionState::Connected | ConnectionState::Streaming ConnectionState::Connected | ConnectionState::Streaming
@@ -435,9 +504,41 @@ fn draw_connection_row(
if is_connected { if is_connected {
connection.disconnect(); connection.disconnect();
} else if !config.port.is_empty() { } else if !config.port.is_empty() {
connection.connect(&config.port, 12, 7); connection.connect(
&config.port,
12,
7,
config.mode.baud_rate(),
config.mode.protocol(),
recorder.clone(),
);
} }
} }
};
if is_narrow {
ui.vertical(|ui| {
draw_port_status(ui, config);
ui.add_space(METRICS.item_gap);
draw_connect_button(ui, config);
});
} else {
ui.horizontal(|ui| {
ui.add(
egui::Image::new(egui::include_image!("../static/cpu.png"))
.fit_to_exact_size(egui::vec2(72.0, 72.0)),
);
ui.add_space(METRICS.item_gap);
ui.vertical(|ui| {
draw_port_status(ui, config);
});
ui.add_space(22.0);
draw_connect_button(ui, config);
});
}
// Legacy reconnect/link-protection indicator: // Legacy reconnect/link-protection indicator:
// ui.add_space(18.0); // ui.add_space(18.0);
@@ -453,7 +554,6 @@ fn draw_connection_row(
// "链路保护 关" // "链路保护 关"
// }, // },
// ); // );
});
} }
fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) { fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
@@ -554,7 +654,7 @@ fn draw_mode_body(
} }
fn draw_status_bytes_row(ui: &mut egui::Ui, conn_state: ConnectionState, stats: SerialIoStats) { fn draw_status_bytes_row(ui: &mut egui::Ui, conn_state: ConnectionState, stats: SerialIoStats) {
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
ui.colored_label(dim_text(), "状态"); ui.colored_label(dim_text(), "状态");
ui.label(connection_status_text(conn_state)); ui.label(connection_status_text(conn_state));
ui.colored_label(dim_text(), "接收"); ui.colored_label(dim_text(), "接收");
@@ -646,7 +746,7 @@ fn baud_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
.selected_text(config.baud_rate.to_string()) .selected_text(config.baud_rate.to_string())
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
for baud_rate in [ for baud_rate in [
9_600, 19_200, 38_400, 57_600, 115_200, 230_400, 460_800, 921_600, 9_600, 19_200, 38_400, 57_600, 115_200, 230_400, 460_800, 921_600, 1_152_000,
] { ] {
ui.selectable_value(&mut config.baud_rate, baud_rate, baud_rate.to_string()); ui.selectable_value(&mut config.baud_rate, baud_rate, baud_rate.to_string());
} }
@@ -672,59 +772,216 @@ pub fn draw_stats_panel(
ctx: &egui::Context, ctx: &egui::Context,
panel: &mut FloatingPanelState, panel: &mut FloatingPanelState,
force_history: &[f32], force_history: &[f32],
_spatial_force: Option<HudSpatialForce>,
) { ) {
draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| { const PREFERRED_PANEL_WIDTH: f32 = 380.0;
ui.set_min_width(320.0); const PREFERRED_PANEL_HEIGHT: f32 = 340.0;
draw_resultant_force_chart(ui, force_history); 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 panel_width = responsive_panel_width(ctx, PREFERRED_PANEL_WIDTH, 260.0);
let panel_height = responsive_panel_height(ctx, PREFERRED_PANEL_HEIGHT, 220.0);
let viewport = viewport_panel_rect(ctx);
let target_pos = egui::pos2(
viewport.left(),
(viewport.bottom() - panel_height - PANEL_VIEWPORT_MARGIN * 2.0)
.max(viewport.top() + layout::TITLE_BAR_HEIGHT + PANEL_VIEWPORT_MARGIN),
);
let target_x = target_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 = target_pos.y;
egui::Area::new(egui::Id::new("stats_panel"))
.fixed_pos(egui::pos2(x, y))
.constrain_to(viewport_panel_rect(ctx))
.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_max_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_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, values: &[f32]) { const HAND_FORCE_PANEL_MIN_WIDTH: f32 = 220.0;
let latest = values.last().copied().unwrap_or(0.0); const HAND_FORCE_PANEL_MAX_WIDTH: f32 = 500.0;
let max = values.iter().copied().fold(0.0_f32, f32::max); const HAND_FORCE_PANEL_MIN_HEIGHT: f32 = 104.0;
let active_values = values.iter().copied().filter(|value| *value > 0.0); const HAND_FORCE_PANEL_MAX_HEIGHT: f32 = 190.0;
let min = active_values.fold(None, |best: Option<f32>, value| { const HAND_FORCE_PANEL_GAP: f32 = 12.0;
Some(best.map_or(value, |current| current.min(value))) const HAND_FORCE_PANEL_SIDE_MARGIN: f32 = 24.0;
const HAND_FORCE_PANEL_VERTICAL_MARGIN: f32 = 28.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<f32>]) {
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 panel_width = side_width
.min(HAND_FORCE_PANEL_MAX_WIDTH)
.max(HAND_FORCE_PANEL_MIN_WIDTH.min(side_width));
let left_x = screen.left() + side_margin;
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 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,
);
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 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);
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 (target_x, target_y) = if index < left_count {
(left_x, left_top + index as f32 * (panel_height + gap))
} else {
let right_index = index - left_count;
(
right_x,
right_top + right_index as f32 * (panel_height + gap),
)
};
draw_force_chart_area(
ctx,
egui::Id::new(format!("hand_force_panel_{index}")),
egui::pos2(target_x, target_y),
egui::vec2(panel_width, 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],
) {
let anim = ctx.animate_bool(id.with("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 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))
.constrain_to(viewport_panel_rect(ctx))
.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_max_width(size.x);
ui.set_min_height(size.y);
draw_force_chart_panel_contents(ui, code, title, values, size.y);
}); });
});
}
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);
group_frame().show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.colored_label(ACCENT_BLUE, "RF"); ui.colored_label(ACCENT_BLUE, code);
ui.label(style::panel_title("合力")); ui.label(style::panel_title(title));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.colored_label(ACCENT_GREEN, format!("{latest:.1} N")); ui.colored_label(ACCENT_GREEN, format!("{latest:.1} N"));
}); });
}); });
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()));
});
ui.add_space(6.0); ui.add_space(6.0);
paint_resultant_force_chart(ui, values); let chart_height = (content_height - 32.0).max(90.0);
}); paint_resultant_force_chart(ui, values, chart_height);
} }
fn force_metric(ui: &mut egui::Ui, label: &'static str, value: f32) { fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height: 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;
const CHART_POINTS: usize = 42; 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 width = ui.available_width().max(180.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 painter = ui.painter_at(rect);
let radius = egui::CornerRadius::same(5); 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_filled(rect, radius, egui::Color32::from_rgb(10, 18, 22));
painter.rect_stroke( painter.rect_stroke(
@@ -739,7 +996,7 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
rect.right_bottom() - egui::vec2(10.0, 24.0), 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() { if values.is_empty() {
painter.text( painter.text(
@@ -754,12 +1011,13 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
ui.ctx().request_repaint(); ui.ctx().request_repaint();
let time = ui.ctx().input(|input| input.time) as f32;
let enter = ui let enter = ui
.ctx() .ctx()
.animate_bool(egui::Id::new("resultant_force_chart_enter"), true); .animate_bool(egui::Id::new("resultant_force_chart_enter"), true);
let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65; 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 mut points = Vec::with_capacity(values.len());
let start_slot = CHART_POINTS.saturating_sub(values.len()); let start_slot = CHART_POINTS.saturating_sub(values.len());
@@ -773,7 +1031,6 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
} }
let plot_painter = painter.with_clip_rect(chart_rect.expand2(egui::vec2(1.0, 1.0))); let plot_painter = painter.with_clip_rect(chart_rect.expand2(egui::vec2(1.0, 1.0)));
paint_force_sweep(&plot_painter, chart_rect, time);
paint_force_area(&plot_painter, &points, plot_rect); paint_force_area(&plot_painter, &points, plot_rect);
if points.len() >= 2 { if points.len() >= 2 {
@@ -802,7 +1059,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] { 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 normalized = (tick / FORCE_CHART_MAX_N).clamp(0.0, 1.0);
let y = chart_rect.bottom() - normalized * chart_rect.height(); let y = chart_rect.bottom() - normalized * chart_rect.height();
@@ -833,42 +1096,25 @@ fn paint_force_grid(painter: &egui::Painter, rect: egui::Rect, chart_rect: egui:
); );
} }
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( painter.text(
egui::pos2(chart_rect.left(), rect.bottom() - 11.0), egui::pos2(x, rect.bottom() - 11.0),
egui::Align2::LEFT_CENTER, align,
"-4.2s", format!("{label_time:.1}s"),
egui::FontId::monospace(9.0), egui::FontId::monospace(9.0),
ONE_DARK_PRO.text_subtle, 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,
);
}
fn paint_force_sweep(painter: &egui::Painter, chart_rect: egui::Rect, time: f32) {
let sweep = (time * 0.55).fract();
let x = chart_rect.left() + sweep * chart_rect.width();
let sweep_rect = egui::Rect::from_min_max(
egui::pos2(x - 8.0, chart_rect.top()),
egui::pos2(x + 8.0, chart_rect.bottom()),
);
painter.rect_filled(
sweep_rect,
egui::CornerRadius::ZERO,
color_alpha(ONE_DARK_PRO.accent, 20),
);
painter.line_segment(
[
egui::pos2(x, chart_rect.top()),
egui::pos2(x, chart_rect.bottom()),
],
egui::Stroke::new(1.0, color_alpha(ACCENT_GREEN, 95)),
);
} }
fn paint_force_area(painter: &egui::Painter, points: &[egui::Pos2], plot_rect: egui::Rect) { fn paint_force_area(painter: &egui::Painter, points: &[egui::Pos2], plot_rect: egui::Rect) {
@@ -903,32 +1149,53 @@ fn draw_floating_panel(
panel: &mut FloatingPanelState, panel: &mut FloatingPanelState,
title: &'static str, title: &'static str,
id: &'static str, id: &'static str,
preferred_width: f32,
min_width: f32,
add_contents: impl FnOnce(&mut egui::Ui), add_contents: impl FnOnce(&mut egui::Ui),
) { ) {
if panel.visible { if panel.visible {
let mut open = true; let mut open = true;
let mut hide_requested = false; let mut hide_requested = false;
let mut window_rect = None; let mut window_rect = None;
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 default_pos = responsive_panel_pos(ctx, panel.default_pos, panel_width);
let window_response = egui::Window::new(title) let window_response = egui::Window::new(title)
.id(egui::Id::new(id)) .id(egui::Id::new(id))
.open(&mut open) .open(&mut open)
.default_pos(panel.default_pos) .default_pos(default_pos)
.max_width(panel_width)
.max_height(max_height)
.constrain_to(viewport_panel_rect(ctx))
.title_bar(false) .title_bar(false)
.resizable(true) .resizable(true)
.frame(panel_frame(ctx)) .frame(panel_frame(ctx))
.show(ctx, |ui| { .show(ctx, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0);
ui.set_min_width(panel_width);
ui.set_max_width(panel_width);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.add(tag_button("隐藏")).clicked() { if ui
.add(rich_tag_button("隐藏", style::ONE_DARK_PRO.text_dim))
.clicked()
{
hide_requested = true; hide_requested = true;
} }
ui.add_space(6.0); ui.add_space(6.0);
ui.label(style::panel_title(title)); ui.label(style::panel_title(title));
}); });
ui.separator(); ui.separator();
let content_max_height = (max_height - 60.0).max(120.0);
egui::ScrollArea::vertical()
.max_height(content_max_height)
.auto_shrink([false, true])
.show(ui, |ui| {
ui.set_min_width(panel_width);
ui.set_max_width(panel_width);
add_contents(ui); add_contents(ui);
}); });
});
if let Some(response) = window_response { if let Some(response) = window_response {
window_rect = Some(response.response.rect); window_rect = Some(response.response.rect);
@@ -982,9 +1249,10 @@ fn draw_center_floating_panel(
collapsed_label: &'static str, collapsed_label: &'static str,
add_contents: impl FnOnce(&mut egui::Ui), add_contents: impl FnOnce(&mut egui::Ui),
) { ) {
const PANEL_WIDTH: f32 = 520.0; const PREFERRED_PANEL_WIDTH: f32 = 520.0;
const SLIDE_DISTANCE: f32 = 18.0; const SLIDE_DISTANCE: f32 = 18.0;
let panel_width = responsive_panel_width(ctx, PREFERRED_PANEL_WIDTH, 320.0);
let anim = advance_center_panel_anim(ctx, panel); let anim = advance_center_panel_anim(ctx, panel);
let eased = ease_in_out(anim); let eased = ease_in_out(anim);
@@ -1016,8 +1284,8 @@ fn draw_center_floating_panel(
) )
.order(egui::Order::Tooltip) .order(egui::Order::Tooltip)
.show(ctx, |ui| { .show(ctx, |ui| {
let response = center_panel_shell(ui, PANEL_WIDTH, |ui| { let response = center_panel_shell(ui, panel_width, |ui| {
ui.set_width(PANEL_WIDTH); ui.set_width(panel_width);
add_contents(ui); add_contents(ui);
ui.add_space(6.0); ui.add_space(6.0);
let handle_rect = allocate_center_panel_handle(ui); let handle_rect = allocate_center_panel_handle(ui);
@@ -1350,7 +1618,7 @@ pub fn draw_recording_toolbar(ui: &mut egui::Ui, recorder: &Recorder, export_pat
let duration_ms = recorder.duration_ms(); let duration_ms = recorder.duration_ms();
ui.separator(); ui.separator();
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
ui.colored_label(ACCENT_RED, "● REC"); ui.colored_label(ACCENT_RED, "● REC");
ui.colored_label( ui.colored_label(
ONE_DARK_PRO.text, ONE_DARK_PRO.text,
@@ -1358,7 +1626,7 @@ pub fn draw_recording_toolbar(ui: &mut egui::Ui, recorder: &Recorder, export_pat
); );
}); });
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
// Full recording // Full recording
let rec_btn = if is_recording { let rec_btn = if is_recording {
tag_button("⏹ 停止") tag_button("⏹ 停止")
@@ -1444,6 +1712,15 @@ pub fn draw_recording_toolbar(ui: &mut egui::Ui, recorder: &Recorder, export_pat
}); });
// Export path // Export path
if narrow_panel(ui) {
ui.vertical(|ui| {
ui.colored_label(dim_text(), "导出路径");
ui.add_sized(
egui::vec2(ui.available_width().max(180.0), METRICS.field_height),
egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"),
);
});
} else {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.colored_label(dim_text(), "导出路径"); ui.colored_label(dim_text(), "导出路径");
ui.add_sized( ui.add_sized(
@@ -1454,6 +1731,7 @@ pub fn draw_recording_toolbar(ui: &mut egui::Ui, recorder: &Recorder, export_pat
egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"), egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"),
); );
}); });
}
} }
// ── Export Panel (floating) ──────────────────────────────────────────── // ── Export Panel (floating) ────────────────────────────────────────────
@@ -1465,10 +1743,20 @@ pub fn draw_export_panel(
recorder: &Recorder, recorder: &Recorder,
export_path: &mut String, export_path: &mut String,
) { ) {
draw_floating_panel(ctx, panel, "录制导出", "export_panel", |ui| { let panel_width = responsive_panel_width(ctx, 340.0, 280.0);
ui.set_min_width(300.0); draw_floating_panel(
ctx,
panel,
"录制导出",
"export_panel",
340.0,
280.0,
|ui| {
ui.set_min_width(panel_width);
ui.set_max_width(panel_width);
draw_recording_toolbar(ui, recorder, export_path); draw_recording_toolbar(ui, recorder, export_path);
}); },
);
} }
// ── Matrix Config Panel ──────────────────────────────────────────────── // ── Matrix Config Panel ────────────────────────────────────────────────
@@ -1497,10 +1785,19 @@ pub fn draw_matrix_config_panel(
panel: &mut FloatingPanelState, panel: &mut FloatingPanelState,
config: &mut MatrixConfigState, config: &mut MatrixConfigState,
) { ) {
draw_floating_panel(ctx, panel, "矩阵配置", "matrix_config_panel", |ui| { let panel_width = responsive_panel_width(ctx, 380.0, 280.0);
ui.set_min_width(280.0); draw_floating_panel(
ctx,
panel,
"矩阵配置",
"matrix_config_panel",
380.0,
280.0,
|ui| {
ui.set_min_width(panel_width);
ui.set_max_width(panel_width);
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
ui.label(style::field_label("矩阵尺寸")); ui.label(style::field_label("矩阵尺寸"));
ui.add_space(METRICS.item_gap); ui.add_space(METRICS.item_gap);
ui.label(style::value_text("")); ui.label(style::value_text(""));
@@ -1517,7 +1814,7 @@ pub fn draw_matrix_config_panel(
ui.add_space(METRICS.item_gap); ui.add_space(METRICS.item_gap);
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
ui.label(style::field_label("预设")); ui.label(style::field_label("预设"));
for (r, c, name) in &[ for (r, c, name) in &[
(12, 7, "12×7"), (12, 7, "12×7"),
@@ -1526,7 +1823,10 @@ pub fn draw_matrix_config_panel(
(48, 24, "48×24"), (48, 24, "48×24"),
(64, 32, "64×32"), (64, 32, "64×32"),
] { ] {
if ui.add(tag_button(*name)).clicked() { if ui
.add(rich_tag_button(*name, style::ONE_DARK_PRO.text_dim))
.clicked()
{
config.rows = *r; config.rows = *r;
config.cols = *c; config.cols = *c;
} }
@@ -1535,7 +1835,7 @@ pub fn draw_matrix_config_panel(
ui.add_space(METRICS.item_gap); ui.add_space(METRICS.item_gap);
ui.horizontal(|ui| { ui.horizontal_wrapped(|ui| {
ui.label(style::field_label("色域范围")); ui.label(style::field_label("色域范围"));
ui.add_space(METRICS.item_gap); ui.add_space(METRICS.item_gap);
ui.label(style::value_text("最小")); ui.label(style::value_text("最小"));
@@ -1556,16 +1856,26 @@ pub fn draw_matrix_config_panel(
ui.add_space(METRICS.row_gap); ui.add_space(METRICS.row_gap);
if ui.add(tag_button("重置默认")).clicked() { if ui
.add(rich_tag_button("重置默认", style::ONE_DARK_PRO.text_dim))
.clicked()
{
*config = MatrixConfigState::default(); *config = MatrixConfigState::default();
} }
}); },
);
} }
pub fn panel_restore_item(ui: &mut egui::Ui, title: &'static str, panel: &mut FloatingPanelState) { 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 { if panel.visible {
ui.add_enabled(false, egui::Button::new(format!("{title} 已显示"))); ui.add_enabled_ui(false, |ui| {
} else if ui.button(format!("显示 {title}")).clicked() { 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; panel.visible = true;
ui.close(); ui.close();
} }

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);
} }
@@ -497,13 +489,13 @@ fn fs_hand_dot(in: DotVertexOutput) -> @location(0) vec4f {
// Use a compact bead so the response feels like it lives on the membrane mesh. // Use a compact bead so the response feels like it lives on the membrane mesh.
let core = circle_alpha(in.local, 0.48, 0.07); let core = circle_alpha(in.local, 0.48, 0.07);
let halo = circle_alpha(in.local, 0.74, 0.14) * 0.12; let halo = circle_alpha(in.local, 0.74, 0.14) * (0.10 + intensity * 0.26);
// Keep the fingertip matrix visually close to JE-Skin's cyan model dots, // Match Finger mode's pressure gradient so each hand region remains readable.
// while still letting pressure brighten the bead a little. let idle = vec3f(0.060, 0.250, 0.320);
let cyan = vec3f(0.34, 0.86, 1.0); let gradient = sample_range_color(intensity);
let hot = sample_range_color(intensity); let color = mix(idle, gradient, smoothstep(0.0, 0.18, intensity))
let color = mix(cyan, hot, 0.22) * mix(0.82, 1.16, intensity); * mix(0.78, 1.18, intensity);
return output_color(color, max(core, halo)); return output_color(color, max(core, halo));
} }
@@ -590,13 +582,13 @@ fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVe
fn fs_hand_palm_dot(in: DotVertexOutput) -> @location(0) vec4f { fn fs_hand_palm_dot(in: DotVertexOutput) -> @location(0) vec4f {
let intensity = saturate(in.intensity); let intensity = saturate(in.intensity);
let pixel = chip_pixel_alpha(in.local, 0.52, 0.070); let pixel = chip_pixel_alpha(in.local, 0.52, 0.070);
let glow = circle_alpha(in.local, 0.95, 0.22) * intensity * 0.30; let glow = circle_alpha(in.local, 0.95, 0.22) * intensity * 0.36;
let cold = vec3f(0.070, 0.340, 0.360); let cold = vec3f(0.070, 0.340, 0.360);
let active_color = mix(vec3f(0.110, 0.620, 0.420), vec3f(0.460, 1.000, 0.210), smoothstep(0.08, 1.0, intensity)); let gradient = sample_range_color(intensity);
let color = mix(cold, active_color, smoothstep(0.02, 0.72, intensity)) let color = mix(cold, gradient, smoothstep(0.0, 0.20, intensity))
* (0.54 + intensity * 1.10) * (0.58 + intensity * 1.04)
+ vec3f(0.28, 1.0, 0.36) * glow * 0.90; + gradient * glow * 0.72;
let alpha = max(pixel * (0.20 + intensity * 0.76), glow); let alpha = max(pixel * (0.20 + intensity * 0.76), glow);
return output_color(color, alpha); return output_color(color, alpha);

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>