1276 lines
41 KiB
Rust
1276 lines
41 KiB
Rust
use crate::breakout::{BreakoutGame, control_from_matrix};
|
||
use crate::connection::{ConnectionManager, ConnectionState};
|
||
use crate::force::{ForceEstimatorState, HAND_FINGERTIP_COUNT, HudSpatialForce};
|
||
use crate::recording::Recorder;
|
||
use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
|
||
use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout};
|
||
use crate::ui::SerialMode;
|
||
use crate::{
|
||
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
||
render::{
|
||
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, PressureSamples,
|
||
WgpuBackgroundCallback,
|
||
},
|
||
ui::{
|
||
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
|
||
draw_config_panel, draw_hand_force_panels, panel_restore_item,
|
||
},
|
||
};
|
||
use eframe::{egui, egui_wgpu};
|
||
use std::{
|
||
sync::Arc,
|
||
time::{Duration, Instant},
|
||
};
|
||
|
||
const SUMMARY_POINTS_PER_SERIES: usize = 42;
|
||
const HAND_FORCE_PANEL_COUNT: usize = 7;
|
||
const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44];
|
||
const HAND_IMAGE_SIZE_PX: [f32; 2] = [971.0, 1117.0];
|
||
const HAND_FINGERTIP_FORCE_ANCHORS_PX: [[f32; 2]; HAND_FINGERTIP_COUNT] = [
|
||
[265.0, 495.0],
|
||
[359.0, 260.0],
|
||
[482.0, 228.0],
|
||
[596.0, 265.0],
|
||
[693.0, 370.0],
|
||
];
|
||
const RATE_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
|
||
const DATA_IDLE_PANEL_EXIT_DELAY: Duration = Duration::from_secs(3);
|
||
|
||
pub struct EskinDesktopApp {
|
||
connect_panel: FloatingPanelState,
|
||
connect_state: ConnectPanelState,
|
||
connection: Arc<ConnectionManager>,
|
||
pressure_matrix: PressureFrame,
|
||
hand_pressure: PressureSamples,
|
||
scene_panel: FloatingPanelState,
|
||
config_panel: FloatingPanelState,
|
||
config_state: ConfigPanelState,
|
||
stats_panel: FloatingPanelState,
|
||
// New: recording, export, matrix config, signal charts
|
||
recorder: Recorder,
|
||
export_panel: FloatingPanelState,
|
||
export_path: String,
|
||
matrix_config_panel: FloatingPanelState,
|
||
matrix_config: MatrixConfigState,
|
||
signal_history: Vec<f32>,
|
||
hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT],
|
||
force_estimator: ForceEstimatorState,
|
||
latest_spatial_force: Option<HudSpatialForce>,
|
||
latest_hand_spatial_forces: [Option<HudSpatialForce>; HAND_FINGERTIP_COUNT],
|
||
spatial_force_panel_force: Option<HudSpatialForce>,
|
||
latest_raw_matrix: Vec<u32>,
|
||
latest_matrix_rows: u32,
|
||
latest_matrix_cols: u32,
|
||
last_sample_at: Option<Instant>,
|
||
last_color_debug_at: Option<Instant>,
|
||
breakout_visible: bool,
|
||
breakout_game: BreakoutGame,
|
||
live_rates: LiveRateMeters,
|
||
context_menu_open: bool,
|
||
context_menu_pos: egui::Pos2,
|
||
active_mode: ActiveMode,
|
||
}
|
||
|
||
struct LiveRateMeters {
|
||
window_started_at: Instant,
|
||
window_rx_frames: u64,
|
||
window_render_frames: u32,
|
||
sample_rate_hz: f32,
|
||
render_rate_hz: f32,
|
||
}
|
||
|
||
impl LiveRateMeters {
|
||
fn new() -> Self {
|
||
Self {
|
||
window_started_at: Instant::now(),
|
||
window_rx_frames: 0,
|
||
window_render_frames: 0,
|
||
sample_rate_hz: 0.0,
|
||
render_rate_hz: 0.0,
|
||
}
|
||
}
|
||
|
||
fn tick_render(&mut self, total_rx_frames: u64) {
|
||
if total_rx_frames < self.window_rx_frames {
|
||
self.window_rx_frames = total_rx_frames;
|
||
self.sample_rate_hz = 0.0;
|
||
}
|
||
|
||
self.window_render_frames = self.window_render_frames.saturating_add(1);
|
||
|
||
let now = Instant::now();
|
||
let elapsed = now.duration_since(self.window_started_at);
|
||
if elapsed < RATE_UPDATE_INTERVAL {
|
||
return;
|
||
}
|
||
|
||
let seconds = elapsed.as_secs_f32().max(0.001);
|
||
let rx_delta = total_rx_frames.saturating_sub(self.window_rx_frames);
|
||
self.sample_rate_hz = rx_delta as f32 / seconds;
|
||
self.render_rate_hz = self.window_render_frames as f32 / seconds;
|
||
self.window_started_at = now;
|
||
self.window_rx_frames = total_rx_frames;
|
||
self.window_render_frames = 0;
|
||
}
|
||
}
|
||
|
||
impl EskinDesktopApp {
|
||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||
apply_fonts(&cc.egui_ctx);
|
||
apply_theme(&cc.egui_ctx, &ONE_DARK_PRO);
|
||
|
||
let wgpu_state = cc
|
||
.wgpu_render_state
|
||
.as_ref()
|
||
.expect("need open eframe wgpu renderer feature");
|
||
|
||
let mut renderer = wgpu_state.renderer.write();
|
||
renderer
|
||
.callback_resources
|
||
.insert(BackgroundRenderResources::new(
|
||
&wgpu_state.device,
|
||
&wgpu_state.queue,
|
||
&wgpu_state.target_format,
|
||
MATRIX_ROWS,
|
||
MATRIX_COLS,
|
||
));
|
||
|
||
Self {
|
||
connect_panel: FloatingPanelState::new([0.0, 0.0], [layout::RIGHT_TAG_X, 48.0]),
|
||
connect_state: ConnectPanelState::default(),
|
||
connection: Arc::new(ConnectionManager::new()),
|
||
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
|
||
hand_pressure: Vec::new(),
|
||
scene_panel: FloatingPanelState::new(
|
||
[layout::LEFT_X, layout::TOP_Y],
|
||
[layout::LEFT_TAG_X, layout::TOP_Y],
|
||
),
|
||
config_panel: FloatingPanelState::new(
|
||
[layout::RIGHT_X, layout::TOP_Y],
|
||
[layout::RIGHT_TAG_X, layout::TOP_Y],
|
||
),
|
||
config_state: ConfigPanelState::default(),
|
||
stats_panel: FloatingPanelState::new(
|
||
[layout::LEFT_X, layout::LEFT_SECOND_Y],
|
||
[layout::LEFT_TAG_X, layout::LEFT_SECOND_Y],
|
||
),
|
||
recorder: Recorder::full(),
|
||
export_panel: FloatingPanelState::new(
|
||
[layout::LEFT_X, layout::TOP_Y],
|
||
[layout::LEFT_TAG_X, layout::TOP_Y],
|
||
),
|
||
export_path: String::new(),
|
||
matrix_config_panel: FloatingPanelState::new(
|
||
[layout::RIGHT_X, layout::RIGHT_SECOND_Y],
|
||
[layout::RIGHT_TAG_X, layout::RIGHT_SECOND_Y],
|
||
),
|
||
matrix_config: MatrixConfigState::default(),
|
||
signal_history: Vec::with_capacity(128),
|
||
hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)),
|
||
force_estimator: ForceEstimatorState::new(),
|
||
latest_spatial_force: None,
|
||
latest_hand_spatial_forces: [None; HAND_FINGERTIP_COUNT],
|
||
spatial_force_panel_force: None,
|
||
latest_raw_matrix: Vec::new(),
|
||
latest_matrix_rows: MATRIX_ROWS,
|
||
latest_matrix_cols: MATRIX_COLS,
|
||
last_sample_at: None,
|
||
last_color_debug_at: None,
|
||
breakout_visible: false,
|
||
breakout_game: BreakoutGame::default(),
|
||
live_rates: LiveRateMeters::new(),
|
||
context_menu_open: false,
|
||
context_menu_pos: egui::pos2(24.0, 48.0),
|
||
active_mode: ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
|
||
}
|
||
}
|
||
|
||
fn draw_wgpu_background_rect(&self, ui: &mut egui::Ui, rect: egui::Rect) {
|
||
let width = rect.width().max(1.0);
|
||
let height = rect.height().max(1.0);
|
||
|
||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||
rect,
|
||
WgpuBackgroundCallback {
|
||
width,
|
||
height,
|
||
pressure: self.pressure_matrix,
|
||
hand_pressure: self.hand_pressure.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) {
|
||
if !self.recent_sample_is_fresh() {
|
||
return;
|
||
}
|
||
|
||
if matches!(self.active_mode, ActiveMode::Hand(_)) {
|
||
self.paint_hand_spatial_force_overlay(ui, rect);
|
||
return;
|
||
}
|
||
|
||
let Some(force) = self.latest_spatial_force else {
|
||
return;
|
||
};
|
||
|
||
let center = rect.center();
|
||
paint_spatial_force_arrow(ui.painter(), center, force, 34.0, 54.0);
|
||
}
|
||
|
||
fn paint_hand_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) {
|
||
for (force, anchor_px) in self
|
||
.latest_hand_spatial_forces
|
||
.iter()
|
||
.zip(HAND_FINGERTIP_FORCE_ANCHORS_PX)
|
||
{
|
||
let Some(force) = force else {
|
||
continue;
|
||
};
|
||
|
||
let center = hand_image_pixel_to_screen(rect, anchor_px);
|
||
paint_spatial_force_arrow(ui.painter(), center, *force, 20.0, 34.0);
|
||
}
|
||
}
|
||
|
||
fn strongest_hand_spatial_force(&self) -> Option<HudSpatialForce> {
|
||
self.latest_hand_spatial_forces
|
||
.iter()
|
||
.flatten()
|
||
.copied()
|
||
.max_by(|a, b| a.magnitude.total_cmp(&b.magnitude))
|
||
}
|
||
|
||
fn analyze_spatial_force(&mut self, values: &[u32]) {
|
||
if matches!(self.config_state.mode, SerialMode::Hand) {
|
||
self.latest_hand_spatial_forces = self.force_estimator.analyze_fingertips(values);
|
||
self.latest_spatial_force = self.strongest_hand_spatial_force();
|
||
} else {
|
||
self.latest_spatial_force = self.force_estimator.analyze(values);
|
||
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
|
||
}
|
||
}
|
||
|
||
fn connection_has_live_data(&self) -> bool {
|
||
matches!(
|
||
self.connection.state(),
|
||
ConnectionState::Connected | ConnectionState::Streaming
|
||
)
|
||
}
|
||
|
||
fn recent_sample_is_fresh(&self) -> bool {
|
||
self.last_sample_at
|
||
.is_some_and(|sample_at| sample_at.elapsed() <= DATA_IDLE_PANEL_EXIT_DELAY)
|
||
}
|
||
|
||
fn hand_force_panels_visible(&self) -> bool {
|
||
self.connection_has_live_data() && self.recent_sample_is_fresh()
|
||
}
|
||
|
||
fn sync_disconnected_live_state(&mut self) {
|
||
if self.connection_has_live_data() {
|
||
return;
|
||
}
|
||
|
||
self.last_sample_at = None;
|
||
self.latest_spatial_force = None;
|
||
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
|
||
self.spatial_force_panel_force = None;
|
||
}
|
||
|
||
fn draw_workspace(&mut self, ui: &mut egui::Ui) {
|
||
let screen = ui.max_rect();
|
||
let workspace = egui::Rect::from_min_max(
|
||
screen.left_top()
|
||
+ egui::vec2(0.0, layout::TITLE_BAR_HEIGHT + layout::CONFIG_BAR_HEIGHT),
|
||
screen.right_bottom(),
|
||
);
|
||
self.draw_wgpu_background_rect(ui, workspace);
|
||
}
|
||
|
||
fn draw_split_workspace(&mut self, ui: &mut egui::Ui) {
|
||
let screen = ui.max_rect();
|
||
let content = egui::Rect::from_min_max(
|
||
screen.left_top() + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT),
|
||
screen.right_bottom(),
|
||
);
|
||
let gutter = 10.0;
|
||
let half_width = (content.width() - gutter).max(1.0) * 0.5;
|
||
let left = egui::Rect::from_min_size(content.min, egui::vec2(half_width, content.height()));
|
||
let right = egui::Rect::from_min_max(
|
||
egui::pos2(left.right() + gutter, content.top()),
|
||
content.right_bottom(),
|
||
);
|
||
|
||
ui.painter()
|
||
.rect_filled(screen, egui::CornerRadius::ZERO, ONE_DARK_PRO.bg);
|
||
paint_split_viewport_shell(ui, left, "DOTMATRIX", "压力矩阵 / WGPU");
|
||
paint_split_viewport_shell(ui, right, "BREAKOUT", "压力控制打砖块");
|
||
|
||
let left_body = split_viewport_body(left);
|
||
let right_body = split_viewport_body(right);
|
||
self.draw_wgpu_background_rect(ui, left_body);
|
||
|
||
let breakout_control = control_from_matrix(
|
||
&self.latest_raw_matrix,
|
||
self.latest_matrix_rows,
|
||
self.latest_matrix_cols,
|
||
);
|
||
self.breakout_game.draw_viewport(
|
||
ui.ctx(),
|
||
right_body,
|
||
&mut self.breakout_visible,
|
||
breakout_control,
|
||
);
|
||
}
|
||
|
||
fn update_pressure_matrix(&mut self) {
|
||
if let Some(sample) = self.connection.take_latest_sample() {
|
||
self.last_sample_at = Some(Instant::now());
|
||
normalize_pressure_sample(
|
||
&sample.matrix,
|
||
sample.rows,
|
||
sample.cols,
|
||
&mut self.pressure_matrix,
|
||
);
|
||
self.hand_pressure = normalize_pressure_samples(&sample.matrix);
|
||
self.latest_raw_matrix.clear();
|
||
self.latest_raw_matrix.extend_from_slice(&sample.matrix);
|
||
self.latest_matrix_rows = sample.rows;
|
||
self.latest_matrix_cols = sample.cols;
|
||
self.log_hand_color_mapping_debug(&sample.matrix);
|
||
|
||
self.analyze_spatial_force(&sample.matrix);
|
||
|
||
let force =
|
||
update_hand_signal_histories(&mut self.hand_signal_histories, &sample.matrix)
|
||
.min(25.6);
|
||
self.signal_history.push(force);
|
||
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
|
||
self.signal_history.remove(0);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_title_bar(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||
let title_bar_height = layout::TITLE_BAR_HEIGHT;
|
||
let title_bar_rect = ui
|
||
.allocate_space(egui::vec2(ui.available_width(), title_bar_height))
|
||
.1;
|
||
|
||
// Paint background
|
||
ui.painter().rect_filled(
|
||
title_bar_rect,
|
||
egui::CornerRadius::ZERO,
|
||
ONE_DARK_PRO.panel_deep,
|
||
);
|
||
ui.painter().line_segment(
|
||
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
|
||
egui::Stroke::new(1.0, ONE_DARK_PRO.border),
|
||
);
|
||
|
||
// Drag-to-move: double-click to maximize, drag to move
|
||
let title_bar_response = ui.interact(
|
||
title_bar_rect,
|
||
egui::Id::new("title_bar"),
|
||
egui::Sense::click_and_drag(),
|
||
);
|
||
if title_bar_response.drag_started() {
|
||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||
}
|
||
if title_bar_response.double_clicked() {
|
||
let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||
ui.ctx()
|
||
.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized));
|
||
}
|
||
|
||
// macOS traffic light buttons (close / minimize / maximize)
|
||
let btn_size = 14.0;
|
||
let btn_spacing = 8.0;
|
||
let btns_start = title_bar_rect.left_center() + egui::vec2(12.0, 0.0);
|
||
|
||
let btn_close_center = btns_start;
|
||
let btn_min_center = btns_start + egui::vec2(btn_size + btn_spacing, 0.0);
|
||
let btn_max_center = btns_start + egui::vec2((btn_size + btn_spacing) * 2.0, 0.0);
|
||
|
||
// Close (red)
|
||
let close_rect =
|
||
egui::Rect::from_center_size(btn_close_center, egui::vec2(btn_size, btn_size));
|
||
let close_resp = ui.interact(close_rect, egui::Id::new("btn_close"), egui::Sense::click());
|
||
let close_color = egui::Color32::from_rgb(255, 95, 86);
|
||
ui.painter()
|
||
.circle_filled(btn_close_center, btn_size / 2.0, close_color);
|
||
// Draw × when hovered
|
||
if close_resp.hovered() {
|
||
ui.painter().line_segment(
|
||
[
|
||
btn_close_center + egui::vec2(-3.0, -3.0),
|
||
btn_close_center + egui::vec2(3.0, 3.0),
|
||
],
|
||
egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)),
|
||
);
|
||
ui.painter().line_segment(
|
||
[
|
||
btn_close_center + egui::vec2(3.0, -3.0),
|
||
btn_close_center + egui::vec2(-3.0, 3.0),
|
||
],
|
||
egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)),
|
||
);
|
||
}
|
||
if close_resp.clicked() {
|
||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||
}
|
||
|
||
// Minimize (yellow)
|
||
let min_rect = egui::Rect::from_center_size(btn_min_center, egui::vec2(btn_size, btn_size));
|
||
let min_resp = ui.interact(min_rect, egui::Id::new("btn_min"), egui::Sense::click());
|
||
let min_color = egui::Color32::from_rgb(255, 189, 46);
|
||
ui.painter()
|
||
.circle_filled(btn_min_center, btn_size / 2.0, min_color);
|
||
if min_resp.hovered() {
|
||
ui.painter().line_segment(
|
||
[
|
||
btn_min_center + egui::vec2(-3.0, 0.0),
|
||
btn_min_center + egui::vec2(3.0, 0.0),
|
||
],
|
||
egui::Stroke::new(1.5, egui::Color32::from_rgb(120, 80, 0)),
|
||
);
|
||
}
|
||
if min_resp.clicked() {
|
||
ui.ctx()
|
||
.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
|
||
}
|
||
|
||
// Maximize (green)
|
||
let max_rect = egui::Rect::from_center_size(btn_max_center, egui::vec2(btn_size, btn_size));
|
||
let max_resp = ui.interact(max_rect, egui::Id::new("btn_max"), egui::Sense::click());
|
||
let max_color = egui::Color32::from_rgb(39, 201, 63);
|
||
ui.painter()
|
||
.circle_filled(btn_max_center, btn_size / 2.0, max_color);
|
||
if max_resp.hovered() {
|
||
let s = 3.0;
|
||
ui.painter().rect_stroke(
|
||
egui::Rect::from_center_size(btn_max_center, egui::vec2(s * 2.0, s * 2.0)),
|
||
egui::CornerRadius::same(1),
|
||
egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 80, 10)),
|
||
egui::StrokeKind::Outside,
|
||
);
|
||
}
|
||
if max_resp.clicked() {
|
||
let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||
ui.ctx()
|
||
.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized));
|
||
}
|
||
}
|
||
|
||
fn draw_floating_panels(
|
||
&mut self,
|
||
ctx: &egui::Context,
|
||
stats: crate::serial_core::serial::SerialIoStats,
|
||
) {
|
||
self.scene_panel.visible = false;
|
||
self.connect_panel.visible = false;
|
||
self.export_panel.visible = false;
|
||
self.matrix_config_panel.visible = false;
|
||
self.context_menu_open = false;
|
||
|
||
if let Some(next_mode) = draw_config_panel(
|
||
ctx,
|
||
&mut self.config_panel,
|
||
&mut self.config_state,
|
||
&self.connection,
|
||
&self.recorder,
|
||
stats,
|
||
self.live_rates.sample_rate_hz,
|
||
self.live_rates.render_rate_hz,
|
||
) {
|
||
self.switch_mode(next_mode);
|
||
}
|
||
|
||
self.stats_panel.visible = false;
|
||
draw_hand_force_panels(
|
||
ctx,
|
||
self.hand_force_panels_visible(),
|
||
&self.hand_signal_histories,
|
||
);
|
||
}
|
||
|
||
fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
|
||
let (secondary_clicked, pointer_pos, escape_pressed) = ctx.input(|input| {
|
||
(
|
||
input.pointer.secondary_clicked(),
|
||
input.pointer.interact_pos(),
|
||
input.key_pressed(egui::Key::Escape),
|
||
)
|
||
});
|
||
|
||
if secondary_clicked {
|
||
self.context_menu_open = true;
|
||
self.context_menu_pos = pointer_pos.unwrap_or(self.context_menu_pos);
|
||
}
|
||
if escape_pressed {
|
||
self.context_menu_open = false;
|
||
}
|
||
if !self.context_menu_open {
|
||
return;
|
||
}
|
||
|
||
let mut close_menu = false;
|
||
let response = egui::Area::new(egui::Id::new("workspace_context_menu_area"))
|
||
.fixed_pos(self.context_menu_pos)
|
||
.order(egui::Order::Foreground)
|
||
.show(ctx, |ui| {
|
||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||
ui.set_min_width(132.0);
|
||
ui.label("显示面板");
|
||
ui.separator();
|
||
|
||
panel_restore_item(ui, "配置", &mut self.config_panel);
|
||
panel_restore_item(ui, "录制", &mut self.export_panel);
|
||
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
|
||
if self.stats_panel.visible {
|
||
if ui
|
||
.add_sized(
|
||
egui::vec2(ui.available_width(), 0.0),
|
||
egui::Button::new("隐藏 统计"),
|
||
)
|
||
.clicked()
|
||
{
|
||
self.stats_panel.visible = false;
|
||
close_menu = true;
|
||
}
|
||
} else {
|
||
panel_restore_item(ui, "统计", &mut self.stats_panel);
|
||
}
|
||
ui.separator();
|
||
|
||
if ui
|
||
.add_sized(
|
||
egui::vec2(ui.available_width(), 0.0),
|
||
egui::Button::new("全部显示"),
|
||
)
|
||
.clicked()
|
||
{
|
||
self.config_panel.visible = true;
|
||
self.export_panel.visible = true;
|
||
self.matrix_config_panel.visible = true;
|
||
self.stats_panel.visible = true;
|
||
close_menu = true;
|
||
}
|
||
});
|
||
});
|
||
|
||
let clicked_outside = ctx.input(|input| {
|
||
input.pointer.primary_clicked()
|
||
&& input
|
||
.pointer
|
||
.interact_pos()
|
||
.is_some_and(|pos| !response.response.rect.contains(pos))
|
||
});
|
||
if close_menu || clicked_outside {
|
||
self.context_menu_open = false;
|
||
}
|
||
}
|
||
|
||
fn switch_mode(&mut self, next: SerialMode) {
|
||
self.connect_state.mode = next;
|
||
self.config_state.mode = next;
|
||
self.config_state.baud_rate = next.baud_rate();
|
||
self.connection.disconnect();
|
||
self.pressure_matrix.fill([0.0, 0.0]);
|
||
self.hand_pressure.clear();
|
||
self.last_sample_at = None;
|
||
self.last_color_debug_at = None;
|
||
self.force_estimator.reset();
|
||
self.latest_spatial_force = None;
|
||
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
|
||
self.spatial_force_panel_force = None;
|
||
self.signal_history.clear();
|
||
self.hand_signal_histories
|
||
.iter_mut()
|
||
.for_each(|history| history.clear());
|
||
|
||
self.active_mode = match next {
|
||
SerialMode::Finger => ActiveMode::Finger(FingerMode {
|
||
rows: self.matrix_config.rows,
|
||
cols: self.matrix_config.cols,
|
||
range: 0..7000,
|
||
dot: true,
|
||
}),
|
||
SerialMode::Hand => ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
|
||
}
|
||
}
|
||
|
||
fn log_hand_color_mapping_debug(&mut self, raw: &[u32]) {
|
||
let now = Instant::now();
|
||
if self
|
||
.last_color_debug_at
|
||
.is_some_and(|last| now.duration_since(last) < Duration::from_secs(1))
|
||
{
|
||
return;
|
||
}
|
||
self.last_color_debug_at = Some(now);
|
||
|
||
let mut parts = Vec::with_capacity(HAND_FORCE_PANEL_COUNT);
|
||
let mut start = 0;
|
||
for (panel_index, (range, cell_count)) in HAND_SENSOR_PANEL_COLOR_RANGES
|
||
.iter()
|
||
.copied()
|
||
.zip(HAND_SENSOR_PANEL_CELL_COUNTS)
|
||
.enumerate()
|
||
{
|
||
let end = start + cell_count;
|
||
let peak = raw
|
||
.get(start..end)
|
||
.unwrap_or(&[])
|
||
.iter()
|
||
.copied()
|
||
.max()
|
||
.unwrap_or(0);
|
||
let cell_range = range.per_cell(cell_count);
|
||
let [intensity, _] = cell_range.normalize(peak);
|
||
parts.push(format!(
|
||
"{} raw_peak={} range_total_max={:.0} cell_range_max={:.0} intensity={:.3}",
|
||
match panel_index {
|
||
0..=4 => format!("F{}", panel_index + 1),
|
||
5 => "Palm-H".to_owned(),
|
||
_ => "Palm-V".to_owned(),
|
||
},
|
||
peak,
|
||
range.max,
|
||
cell_range.max,
|
||
intensity
|
||
));
|
||
start = end;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn hand_image_pixel_to_screen(rect: egui::Rect, point_px: [f32; 2]) -> egui::Pos2 {
|
||
let image_uv = egui::vec2(
|
||
point_px[0] / HAND_IMAGE_SIZE_PX[0].max(1.0),
|
||
point_px[1] / HAND_IMAGE_SIZE_PX[1].max(1.0),
|
||
);
|
||
let viewport_aspect = rect.width() / rect.height().max(1.0);
|
||
let image_aspect = HAND_IMAGE_SIZE_PX[0] / HAND_IMAGE_SIZE_PX[1].max(1.0);
|
||
let mut screen_uv = image_uv;
|
||
|
||
if viewport_aspect > image_aspect {
|
||
let image_width = image_aspect / viewport_aspect;
|
||
screen_uv.x = image_uv.x * image_width + (1.0 - image_width) * 0.5;
|
||
} else {
|
||
let image_height = viewport_aspect / image_aspect;
|
||
screen_uv.y = image_uv.y * image_height + (1.0 - image_height) * 0.5;
|
||
}
|
||
|
||
egui::pos2(
|
||
rect.left() + screen_uv.x * rect.width(),
|
||
rect.top() + screen_uv.y * rect.height(),
|
||
)
|
||
}
|
||
|
||
fn paint_spatial_force_arrow(
|
||
painter: &egui::Painter,
|
||
center: egui::Pos2,
|
||
force: HudSpatialForce,
|
||
base_length: f32,
|
||
magnitude_scale: f32,
|
||
) {
|
||
let magnitude = force.magnitude.clamp(0.0, 1.8);
|
||
let length = base_length + magnitude * magnitude_scale;
|
||
let angle = force.angle_deg.to_radians();
|
||
let direction = egui::vec2(angle.cos(), angle.sin());
|
||
let end = center + direction * length;
|
||
let side = egui::vec2(-direction.y, direction.x);
|
||
let head_length = (length * 0.24).clamp(8.0, 14.0);
|
||
let head_width = head_length * 0.5;
|
||
let color = egui::Color32::from_rgb(255, 196, 54);
|
||
let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58);
|
||
|
||
painter.line_segment([center, end], egui::Stroke::new(8.0, glow));
|
||
painter.line_segment([center, end], egui::Stroke::new(2.4, color));
|
||
painter.line_segment(
|
||
[end, end - direction * head_length + side * head_width],
|
||
egui::Stroke::new(2.4, color),
|
||
);
|
||
painter.line_segment(
|
||
[end, end - direction * head_length - side * head_width],
|
||
egui::Stroke::new(2.4, color),
|
||
);
|
||
painter.circle_filled(center, 3.8, color);
|
||
}
|
||
|
||
fn update_hand_signal_histories(
|
||
histories: &mut [Vec<f32>; HAND_FORCE_PANEL_COUNT],
|
||
raw_values: &[u32],
|
||
) -> f32 {
|
||
let mut offset = 0usize;
|
||
let mut total_force = 0.0;
|
||
for (index, (history, sample_count)) in histories
|
||
.iter_mut()
|
||
.zip(HAND_FORCE_SEGMENT_COUNTS)
|
||
.enumerate()
|
||
{
|
||
let raw_total = raw_values
|
||
.get(offset..offset + sample_count)
|
||
.unwrap_or(&[])
|
||
.iter()
|
||
.fold(0_u64, |sum, value| sum + *value as u64)
|
||
.min(u32::MAX as u64) as u32;
|
||
let force = raw_to_hand_segment_g(index, raw_total).min(20.6);
|
||
let force = if force <= 0.1 { 0.0 } else { force };
|
||
total_force += force;
|
||
history.push(force);
|
||
if history.len() > SUMMARY_POINTS_PER_SERIES {
|
||
history.remove(0);
|
||
}
|
||
|
||
offset += sample_count;
|
||
}
|
||
|
||
total_force
|
||
}
|
||
|
||
fn raw_to_hand_segment_g(index: usize, raw: u32) -> f32 {
|
||
match index {
|
||
0 => raw_to_g1(raw),
|
||
1 => raw_to_g2(raw),
|
||
2 => raw_to_g3(raw),
|
||
3 => raw_to_g4(raw),
|
||
4 => raw_to_g5(raw),
|
||
// 5 => raw_to_g6(raw),
|
||
// 6 => raw_to_g7(raw),
|
||
_ => raw_to_gd(raw),
|
||
}
|
||
}
|
||
|
||
fn raw_to_gd(raw: u32) -> f32 {
|
||
const RAW: [u32; 11] = [
|
||
0, 486, 5310, 8130, 10005, 12883, 14700, 18141, 19877, 26270, 32648,
|
||
];
|
||
const FORCE_CENTI_N: [f32; 11] = [
|
||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
|
||
];
|
||
|
||
if raw <= RAW[0] {
|
||
return FORCE_CENTI_N[0] / 100.0;
|
||
}
|
||
|
||
if raw >= RAW[RAW.len() - 1] {
|
||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||
}
|
||
|
||
let mut left = 0usize;
|
||
let mut right = RAW.len() - 1;
|
||
while left + 1 < right {
|
||
let mid = (left + right) / 2;
|
||
if RAW[mid] <= raw {
|
||
left = mid;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
let raw_left = RAW[left] as f32;
|
||
let raw_right = RAW[right] as f32;
|
||
let span = (raw_right - raw_left).max(1.0);
|
||
let ratio = (raw as f32 - raw_left) / span;
|
||
|
||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||
}
|
||
|
||
fn raw_to_g1(raw: u32) -> f32 {
|
||
const RAW: [u32; 11] = [
|
||
0, 106443, 365678, 449286, 532425, 608187, 685522, 797058, 886997, 1082570, 1244793,
|
||
];
|
||
const FORCE_CENTI_N: [f32; 11] = [
|
||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
|
||
];
|
||
|
||
if raw <= RAW[0] {
|
||
return FORCE_CENTI_N[0] / 100.0;
|
||
}
|
||
|
||
if raw >= RAW[RAW.len() - 1] {
|
||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||
}
|
||
|
||
let mut left = 0usize;
|
||
let mut right = RAW.len() - 1;
|
||
while left + 1 < right {
|
||
let mid = (left + right) / 2;
|
||
if RAW[mid] <= raw {
|
||
left = mid;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
let raw_left = RAW[left] as f32;
|
||
let raw_right = RAW[right] as f32;
|
||
let span = (raw_right - raw_left).max(1.0);
|
||
let ratio = (raw as f32 - raw_left) / span;
|
||
|
||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||
}
|
||
|
||
fn raw_to_g2(raw: u32) -> f32 {
|
||
const RAW: [u32; 11] = [
|
||
0, 111143, 310153, 375593, 427920, 497433, 548676, 648392, 715118, 877487, 994887,
|
||
];
|
||
const FORCE_CENTI_N: [f32; 11] = [
|
||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
|
||
];
|
||
|
||
if raw <= RAW[0] {
|
||
return FORCE_CENTI_N[0] / 100.0;
|
||
}
|
||
|
||
if raw >= RAW[RAW.len() - 1] {
|
||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||
}
|
||
|
||
let mut left = 0usize;
|
||
let mut right = RAW.len() - 1;
|
||
while left + 1 < right {
|
||
let mid = (left + right) / 2;
|
||
if RAW[mid] <= raw {
|
||
left = mid;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
let raw_left = RAW[left] as f32;
|
||
let raw_right = RAW[right] as f32;
|
||
let span = (raw_right - raw_left).max(1.0);
|
||
let ratio = (raw as f32 - raw_left) / span;
|
||
|
||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||
}
|
||
|
||
fn raw_to_g3(raw: u32) -> f32 {
|
||
const RAW: [u32; 11] = [
|
||
0, 66898, 271692, 354094, 443663, 534994, 603246, 721942, 837520, 1030111, 1216809,
|
||
];
|
||
const FORCE_CENTI_N: [f32; 11] = [
|
||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
|
||
];
|
||
|
||
if raw <= RAW[0] {
|
||
return FORCE_CENTI_N[0] / 100.0;
|
||
}
|
||
|
||
if raw >= RAW[RAW.len() - 1] {
|
||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||
}
|
||
|
||
let mut left = 0usize;
|
||
let mut right = RAW.len() - 1;
|
||
while left + 1 < right {
|
||
let mid = (left + right) / 2;
|
||
if RAW[mid] <= raw {
|
||
left = mid;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
let raw_left = RAW[left] as f32;
|
||
let raw_right = RAW[right] as f32;
|
||
let span = (raw_right - raw_left).max(1.0);
|
||
let ratio = (raw as f32 - raw_left) / span;
|
||
|
||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||
}
|
||
|
||
fn raw_to_g4(raw: u32) -> f32 {
|
||
const RAW: [u32; 11] = [
|
||
0, 122337, 381708, 480527, 577445, 677301, 762306, 906751, 1019182, 1254601, 1415051,
|
||
];
|
||
const FORCE_CENTI_N: [f32; 11] = [
|
||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
|
||
];
|
||
|
||
if raw <= RAW[0] {
|
||
return FORCE_CENTI_N[0] / 100.0;
|
||
}
|
||
|
||
if raw >= RAW[RAW.len() - 1] {
|
||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||
}
|
||
|
||
let mut left = 0usize;
|
||
let mut right = RAW.len() - 1;
|
||
while left + 1 < right {
|
||
let mid = (left + right) / 2;
|
||
if RAW[mid] <= raw {
|
||
left = mid;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
let raw_left = RAW[left] as f32;
|
||
let raw_right = RAW[right] as f32;
|
||
let span = (raw_right - raw_left).max(1.0);
|
||
let ratio = (raw as f32 - raw_left) / span;
|
||
|
||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||
}
|
||
|
||
fn raw_to_g5(raw: u32) -> f32 {
|
||
const RAW: [u32; 11] = [
|
||
0, 82466, 287752, 356167, 433286, 506855, 604014, 671789, 770692, 887287, 1032058,
|
||
];
|
||
const FORCE_CENTI_N: [f32; 11] = [
|
||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
|
||
];
|
||
|
||
if raw <= RAW[0] {
|
||
return FORCE_CENTI_N[0] / 100.0;
|
||
}
|
||
|
||
if raw >= RAW[RAW.len() - 1] {
|
||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||
}
|
||
|
||
let mut left = 0usize;
|
||
let mut right = RAW.len() - 1;
|
||
while left + 1 < right {
|
||
let mid = (left + right) / 2;
|
||
if RAW[mid] <= raw {
|
||
left = mid;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
let raw_left = RAW[left] as f32;
|
||
let raw_right = RAW[right] as f32;
|
||
let span = (raw_right - raw_left).max(1.0);
|
||
let ratio = (raw as f32 - raw_left) / span;
|
||
|
||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||
}
|
||
|
||
fn raw_to_g6(raw: u32) -> f32 {
|
||
const RAW: [u32; 11] = [
|
||
0, 0, 4290, 8096, 10567, 14856, 19685, 43856, 84675, 114001, 129068,
|
||
];
|
||
const FORCE_CENTI_N: [f32; 11] = [
|
||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
|
||
];
|
||
|
||
if raw <= RAW[0] {
|
||
return FORCE_CENTI_N[0] / 100.0;
|
||
}
|
||
|
||
if raw >= RAW[RAW.len() - 1] {
|
||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||
}
|
||
|
||
let mut left = 0usize;
|
||
let mut right = RAW.len() - 1;
|
||
while left + 1 < right {
|
||
let mid = (left + right) / 2;
|
||
if RAW[mid] <= raw {
|
||
left = mid;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
let raw_left = RAW[left] as f32;
|
||
let raw_right = RAW[right] as f32;
|
||
let span = (raw_right - raw_left).max(1.0);
|
||
let ratio = (raw as f32 - raw_left) / span;
|
||
|
||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||
}
|
||
|
||
fn raw_to_g7(raw: u32) -> f32 {
|
||
const RAW: [u32; 11] = [
|
||
0, 10081, 40910, 49944, 64291, 68299, 79282, 97767, 113567, 140520, 167132,
|
||
];
|
||
const FORCE_CENTI_N: [f32; 11] = [
|
||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0,
|
||
];
|
||
|
||
if raw <= RAW[0] {
|
||
return FORCE_CENTI_N[0] / 100.0;
|
||
}
|
||
|
||
if raw >= RAW[RAW.len() - 1] {
|
||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||
}
|
||
|
||
let mut left = 0usize;
|
||
let mut right = RAW.len() - 1;
|
||
while left + 1 < right {
|
||
let mid = (left + right) / 2;
|
||
if RAW[mid] <= raw {
|
||
left = mid;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
let raw_left = RAW[left] as f32;
|
||
let raw_right = RAW[right] as f32;
|
||
let span = (raw_right - raw_left).max(1.0);
|
||
let ratio = (raw as f32 - raw_left) / span;
|
||
|
||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||
}
|
||
|
||
fn paint_split_viewport_shell(
|
||
ui: &egui::Ui,
|
||
rect: egui::Rect,
|
||
title: &'static str,
|
||
subtitle: &'static str,
|
||
) {
|
||
let painter = ui.painter();
|
||
painter.rect_filled(
|
||
rect,
|
||
egui::CornerRadius::same(8),
|
||
egui::Color32::from_rgb(11, 17, 23),
|
||
);
|
||
painter.rect_stroke(
|
||
rect,
|
||
egui::CornerRadius::same(8),
|
||
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||
egui::StrokeKind::Outside,
|
||
);
|
||
painter.line_segment(
|
||
[
|
||
rect.left_top() + egui::vec2(12.0, 34.0),
|
||
rect.right_top() + egui::vec2(-12.0, 34.0),
|
||
],
|
||
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||
);
|
||
painter.text(
|
||
rect.left_top() + egui::vec2(14.0, 16.0),
|
||
egui::Align2::LEFT_CENTER,
|
||
title,
|
||
egui::FontId::monospace(13.0),
|
||
ONE_DARK_PRO.accent,
|
||
);
|
||
painter.text(
|
||
rect.left_top() + egui::vec2(122.0, 16.0),
|
||
egui::Align2::LEFT_CENTER,
|
||
subtitle,
|
||
egui::FontId::proportional(12.0),
|
||
ONE_DARK_PRO.text_dim,
|
||
);
|
||
}
|
||
|
||
fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
|
||
egui::Rect::from_min_max(
|
||
rect.left_top() + egui::vec2(10.0, 44.0),
|
||
rect.right_bottom() - egui::vec2(10.0, 10.0),
|
||
)
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct PressureColorRange {
|
||
min: f32,
|
||
max: f32,
|
||
gamma: f32,
|
||
}
|
||
|
||
// The shader begins its orange-to-red transition at intensity 0.66. With this
|
||
// gamma, a raw cell value at 80% of its configured range maps to that point.
|
||
const PRESSURE_COLOR_GAMMA: f32 = 1.86;
|
||
|
||
impl PressureColorRange {
|
||
const fn new(min: u32, max: u32) -> Self {
|
||
Self {
|
||
min: min as f32,
|
||
max: max as f32,
|
||
gamma: PRESSURE_COLOR_GAMMA,
|
||
}
|
||
}
|
||
|
||
fn normalize(self, value: u32) -> [f32; 2] {
|
||
let raw = value as f32;
|
||
let span = (self.max - self.min).max(1.0);
|
||
let mapped = ((raw - self.min) / span).clamp(0.0, 1.0);
|
||
|
||
let intensity = if raw <= self.min + 4.0 {
|
||
0.0
|
||
} else {
|
||
mapped.powf(self.gamma)
|
||
};
|
||
|
||
let display_value = if raw <= self.min + 4.0 {
|
||
0.0
|
||
} else {
|
||
raw.round().min(9999.0)
|
||
};
|
||
|
||
[intensity, display_value]
|
||
}
|
||
|
||
fn per_cell(self, cell_count: usize) -> Self {
|
||
let cells = cell_count.max(1) as f32;
|
||
Self {
|
||
min: self.min / cells,
|
||
max: self.max / cells,
|
||
gamma: self.gamma,
|
||
}
|
||
}
|
||
}
|
||
|
||
const DEFAULT_COLOR_RANGE: PressureColorRange = PressureColorRange::new(0, 7000);
|
||
|
||
const HAND_SENSOR_PANEL_COLOR_RANGES: [PressureColorRange; HAND_FORCE_PANEL_COUNT] = [
|
||
PressureColorRange::new(0, 1244793),
|
||
PressureColorRange::new(0, 994887),
|
||
PressureColorRange::new(0, 1216809),
|
||
PressureColorRange::new(0, 1415051),
|
||
PressureColorRange::new(0, 1032058),
|
||
PressureColorRange::new(0, 32648),
|
||
PressureColorRange::new(0, 32648),
|
||
];
|
||
|
||
const HAND_FINGER_SENSOR_CELLS: usize = 12 * 7;
|
||
const HAND_SENSOR_PANEL_CELL_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [
|
||
HAND_FINGER_SENSOR_CELLS,
|
||
HAND_FINGER_SENSOR_CELLS,
|
||
HAND_FINGER_SENSOR_CELLS,
|
||
HAND_FINGER_SENSOR_CELLS,
|
||
HAND_FINGER_SENSOR_CELLS,
|
||
5 * 14,
|
||
11 * 4,
|
||
];
|
||
|
||
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
|
||
normalized.fill([0.0, 0.0]);
|
||
|
||
let src_cols = cols.max(1);
|
||
let copy_rows = MATRIX_ROWS.min(rows);
|
||
let copy_cols = MATRIX_COLS.min(cols);
|
||
|
||
for row in 0..copy_rows {
|
||
for col in 0..copy_cols {
|
||
let src_index = (row * src_cols + col) as usize;
|
||
let dst_index = (row * MATRIX_COLS + col) as usize;
|
||
if let Some(value) = raw.get(src_index) {
|
||
normalized[dst_index] = normalize_pressure_value(*value);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn normalize_pressure_samples(raw: &[u32]) -> PressureSamples {
|
||
raw.iter()
|
||
.copied()
|
||
.enumerate()
|
||
.map(|(index, value)| {
|
||
hand_sensor_panel_for_index(index)
|
||
.map(|(range, cell_count)| range.per_cell(cell_count).normalize(value))
|
||
.unwrap_or_else(|| DEFAULT_COLOR_RANGE.normalize(value))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn hand_sensor_panel_for_index(index: usize) -> Option<(PressureColorRange, usize)> {
|
||
let mut start = 0;
|
||
|
||
for (range, cell_count) in HAND_SENSOR_PANEL_COLOR_RANGES
|
||
.iter()
|
||
.copied()
|
||
.zip(HAND_SENSOR_PANEL_CELL_COUNTS)
|
||
{
|
||
let end = start + cell_count;
|
||
if index < end {
|
||
return Some((range, cell_count));
|
||
}
|
||
start = end;
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn normalize_pressure_value(value: u32) -> [f32; 2] {
|
||
DEFAULT_COLOR_RANGE.normalize(value)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn pressure_normalization_makes_low_hand_values_visible() {
|
||
let [zero_intensity, zero_label] = normalize_pressure_value(4);
|
||
assert_eq!(zero_intensity, 0.0);
|
||
assert_eq!(zero_label, 0.0);
|
||
|
||
let [low_intensity, low_label] = normalize_pressure_value(100);
|
||
assert!(
|
||
low_intensity > 0.0,
|
||
"low hand-gateway values should retain a non-zero response"
|
||
);
|
||
assert_eq!(low_label, 100.0);
|
||
|
||
let [max_intensity, max_label] = normalize_pressure_value(7000);
|
||
assert_eq!(max_intensity, 1.0);
|
||
assert_eq!(max_label, 7000.0);
|
||
}
|
||
|
||
#[test]
|
||
fn pressure_normalization_starts_red_transition_at_eighty_percent() {
|
||
let range = PressureColorRange::new(0, 10_000);
|
||
let [intensity, _] = range.normalize(8_000);
|
||
|
||
assert!(
|
||
(intensity - 0.66).abs() < 0.002,
|
||
"80% of the configured raw range should map to the shader's red-transition point"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn hand_panel_ranges_cover_both_palm_boards() {
|
||
let total_cells: usize = HAND_SENSOR_PANEL_CELL_COUNTS.iter().sum();
|
||
let mut raw = vec![0; total_cells];
|
||
let mut start = 0;
|
||
|
||
for (range, cell_count) in HAND_SENSOR_PANEL_COLOR_RANGES
|
||
.iter()
|
||
.copied()
|
||
.zip(HAND_SENSOR_PANEL_CELL_COUNTS)
|
||
{
|
||
raw[start] = (range.max / cell_count as f32).ceil() as u32;
|
||
start += cell_count;
|
||
}
|
||
|
||
let normalized = normalize_pressure_samples(&raw);
|
||
let palm_horizontal_start = HAND_SENSOR_PANEL_CELL_COUNTS[..5].iter().sum::<usize>();
|
||
let palm_vertical_start = palm_horizontal_start + HAND_SENSOR_PANEL_CELL_COUNTS[5];
|
||
|
||
assert_eq!(normalized[palm_horizontal_start][0], 1.0);
|
||
assert_eq!(normalized[palm_vertical_start][0], 1.0);
|
||
}
|
||
}
|
||
|
||
impl eframe::App for EskinDesktopApp {
|
||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||
let ctx = ui.ctx().clone();
|
||
let stats = self.connection.stats();
|
||
self.live_rates.tick_render(stats.rx_frames);
|
||
|
||
self.update_pressure_matrix();
|
||
self.sync_disconnected_live_state();
|
||
self.draw_workspace(ui);
|
||
self.draw_title_bar(ui, frame);
|
||
self.draw_floating_panels(&ctx, stats);
|
||
|
||
// Keep repainting while the wgpu background is a realtime viewport.
|
||
ctx.request_repaint();
|
||
}
|
||
}
|