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, 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, hand_signal_histories: [Vec; HAND_FORCE_PANEL_COUNT], force_estimator: ForceEstimatorState, latest_spatial_force: Option, latest_hand_spatial_forces: [Option; HAND_FINGERTIP_COUNT], spatial_force_panel_force: Option, latest_raw_matrix: Vec, latest_matrix_rows: u32, latest_matrix_cols: u32, last_sample_at: Option, last_color_debug_at: Option, 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 { 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; 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::(); 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(); } }