diff --git a/res/hand.glb b/res/hand.glb new file mode 100644 index 0000000..66cbf0c Binary files /dev/null and b/res/hand.glb differ diff --git a/src/app.rs b/src/app.rs index 4533f7f..40b0c5b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,6 @@ use crate::breakout::{BreakoutGame, control_from_matrix}; use crate::connection::ConnectionManager; +use crate::force::{ForceEstimatorState, HudSpatialForce}; use crate::recording::Recorder; use crate::render::{ActiveMode, FingerMode, HandGatewayMode}; use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout}; @@ -7,7 +8,8 @@ use crate::ui::SerialMode; use crate::{ matrix::{MATRIX_COLS, MATRIX_ROWS}, render::{ - BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback, + BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, PressureSamples, + WgpuBackgroundCallback, }, ui::{ ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState, @@ -18,17 +20,14 @@ use crate::{ use eframe::{egui, egui_wgpu}; use std::sync::Arc; -const DATA_LOG_EVERY_FRAMES: u64 = 30; const SUMMARY_POINTS_PER_SERIES: usize = 42; -const MIN_DISPLAY_FORCE_N: f32 = 0.1; -const MAX_DISPLAY_FORCE_N: f32 = 25.6; pub struct EskinDesktopApp { connect_panel: FloatingPanelState, connect_state: ConnectPanelState, connection: Arc, pressure_matrix: PressureFrame, - data_log_frame: u64, + hand_pressure: PressureSamples, scene_panel: FloatingPanelState, config_panel: FloatingPanelState, config_state: ConfigPanelState, @@ -40,6 +39,8 @@ pub struct EskinDesktopApp { matrix_config_panel: FloatingPanelState, matrix_config: MatrixConfigState, signal_history: Vec, + force_estimator: ForceEstimatorState, + latest_spatial_force: Option, latest_raw_matrix: Vec, latest_matrix_rows: u32, latest_matrix_cols: u32, @@ -77,7 +78,7 @@ impl EskinDesktopApp { connect_state: ConnectPanelState::default(), connection: Arc::new(ConnectionManager::new()), pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT], - data_log_frame: 0, + hand_pressure: Vec::new(), scene_panel: FloatingPanelState::new( [layout::LEFT_X, layout::TOP_Y], [layout::LEFT_TAG_X, layout::TOP_Y], @@ -103,6 +104,8 @@ impl EskinDesktopApp { ), matrix_config: MatrixConfigState::default(), signal_history: Vec::with_capacity(128), + force_estimator: ForceEstimatorState::new(), + latest_spatial_force: None, latest_raw_matrix: Vec::new(), latest_matrix_rows: MATRIX_ROWS, latest_matrix_cols: MATRIX_COLS, @@ -129,9 +132,40 @@ impl EskinDesktopApp { 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) { + 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) { @@ -186,6 +220,7 @@ impl EskinDesktopApp { 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; @@ -194,27 +229,20 @@ impl EskinDesktopApp { // Feed data to recorder self.recorder.add_frame(&sample.matrix); - // JE-Skin summary logic: sum all cells first, then convert raw summary to force. + self.latest_spatial_force = self.force_estimator.analyze(&sample.matrix); + + // Keep JE-Skin's summary path separate from the optional spatial-force vector. let raw_total = sample .matrix .iter() .fold(0_u64, |sum, value| sum + *value as u64) .min(u32::MAX as u64) as u32; - let force = raw_to_g1(raw_total).min(MAX_DISPLAY_FORCE_N); - let force = if force <= MIN_DISPLAY_FORCE_N { - 0.0 - } else { - force - }; + let force = raw_to_g1(raw_total).min(25.6); + let force = if force <= 0.1 { 0.0 } else { force }; self.signal_history.push(force); if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES { self.signal_history.remove(0); } - - self.data_log_frame += 1; - if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 { - log_pressure_sample(&sample.matrix, sample.rows, sample.cols); - } } } @@ -339,7 +367,12 @@ impl EskinDesktopApp { ) { self.switch_mode(next_mode); } - draw_stats_panel(ctx, &mut self.stats_panel, &self.signal_history); + draw_stats_panel( + ctx, + &mut self.stats_panel, + &self.signal_history, + self.latest_spatial_force, + ); draw_export_panel( ctx, &mut self.export_panel, @@ -416,9 +449,13 @@ impl EskinDesktopApp { 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.data_log_frame = 0; + self.hand_pressure.clear(); + self.force_estimator.reset(); + self.latest_spatial_force = None; + self.signal_history.clear(); self.active_mode = match next { SerialMode::Finger => ActiveMode::Finger(FingerMode { @@ -432,23 +469,6 @@ impl EskinDesktopApp { } } -fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) { - let max = raw.iter().copied().max().unwrap_or(0); - let sum: u64 = raw.iter().map(|value| *value as u64).sum(); - let non_zero = raw.iter().filter(|value| **value != 0).count(); - let preview = raw - .iter() - .take(12) - .map(u32::to_string) - .collect::>() - .join(", "); - - println!( - "[pressure] {rows}x{cols} cells={} non_zero={non_zero} max={max} sum={sum} first=[{preview}]", - raw.len() - ); -} - fn raw_to_g1(raw: u32) -> f32 { const RAW: [u32; 12] = [ 0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444, @@ -465,7 +485,6 @@ fn raw_to_g1(raw: u32) -> f32 { 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 right = RAW.len() - 1; while left + 1 < right { @@ -534,9 +553,6 @@ fn split_viewport_body(rect: egui::Rect) -> egui::Rect { } 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]); let src_cols = cols.max(1); @@ -548,19 +564,31 @@ fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut 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) { - let raw_value = *value as f32; - 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 { - 0.0 - } else { - raw_value.round().min(9999.0) - }; - normalized[dst_index] = [mapped, display_value]; + 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 display_value = if raw_value <= RANGE_MIN + 4.0 { + 0.0 + } else { + raw_value.round().min(9999.0) + }; + + [mapped, display_value] +} + impl eframe::App for EskinDesktopApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); diff --git a/src/connection.rs b/src/connection.rs index 23dde90..fb6ded8 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -4,7 +4,9 @@ use std::time::Duration; use crossbeam_channel::{self, Receiver, Sender, TryRecvError}; -use crate::serial_core::serial::{SerialIoStats, SerialPortReadWrite, run_serial_loop}; +use crate::serial_core::serial::{ + SerialIoStats, SerialPortReadWrite, SerialProtocol, run_serial_loop, +}; /// Connection state visible to the UI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -28,6 +30,8 @@ struct Session { handle: JoinHandle<()>, sample_rx: Receiver>, stats_rx: Receiver, + rows: u32, + cols: u32, } /// Thread-safe connection manager that the UI and renderer can share. @@ -72,7 +76,14 @@ impl ConnectionManager { } /// 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, + ) { self.disconnect(); self.set_state(ConnectionState::Connecting); @@ -90,6 +101,8 @@ impl ConnectionManager { &port, rows, cols, + baud_rate, + protocol, &state, &cancel_rx, &sample_tx, @@ -107,6 +120,8 @@ impl ConnectionManager { handle, sample_rx, stats_rx, + rows, + cols, }); } @@ -135,10 +150,12 @@ impl ConnectionManager { loop { match session.sample_rx.try_recv() { Ok(vals) => { - let rows = 12u32; - let cols = 7u32; 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::Disconnected) => break, @@ -163,13 +180,15 @@ fn run_device_loop( port_name: &str, rows: u32, cols: u32, + baud_rate: u32, + protocol: SerialProtocol, state: &Arc>, cancel_rx: &Receiver<()>, sample_tx: &Sender>, stats_tx: &Sender, latest_sample: &Arc>>, ) -> Result<(), Box> { - let port = serialport::new(port_name, 921_600) + let port = serialport::new(port_name, baud_rate) .timeout(Duration::from_millis(100)) .open()?; @@ -182,6 +201,7 @@ fn run_device_loop( &mut rw, rows as usize, cols as usize, + protocol, cancel_rx, sample_tx, Some(stats_tx), diff --git a/src/force.rs b/src/force.rs new file mode 100644 index 0000000..24b258b --- /dev/null +++ b/src/force.rs @@ -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 { + if values.len() != FINGER_SAMPLE_COUNT { + return None; + } + + let pzt_values = values.iter().map(|value| *value as f32).collect::>(); + + 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() + } +} diff --git a/src/main.rs b/src/main.rs index b5290cc..2428c51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ +#![allow(dead_code)] + mod app; mod breakout; mod connection; +mod force; mod matrix; mod model; mod recording; diff --git a/src/render.rs b/src/render.rs index 5413311..9d25a2a 100644 --- a/src/render.rs +++ b/src/render.rs @@ -13,11 +13,13 @@ use std::ops::Range; pub const PRESSURE_CELL_COUNT: usize = (crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize; pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT]; +pub type PressureSamples = Vec<[f32; 2]>; pub struct WgpuBackgroundCallback { pub width: f32, pub height: f32, pub pressure: PressureFrame, + pub hand_pressure: PressureSamples, 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. // 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. @@ -114,7 +120,13 @@ impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback { resources: &mut egui_wgpu::CallbackResources, ) -> Vec { 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() } @@ -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); self.uniform = MatrixUniform::new( width, @@ -628,14 +647,20 @@ impl BackgroundRenderResources { 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. self.hand_dot_instances = build_hand_dot_instances( self.rows, self.cols, self.hand_image_texture.width as f32, self.hand_image_texture.height as f32, - pressure, + hand_pressure, ); queue.write_buffer( &self.hand_dot_instance_buffer, @@ -650,7 +675,7 @@ impl BackgroundRenderResources { self.cols, self.hand_image_texture.width as f32, self.hand_image_texture.height as f32, - pressure, + hand_pressure, ); queue.write_buffer( &self.hand_palm_dot_instance_buffer, @@ -1158,19 +1183,22 @@ fn build_hand_dot_instances( cols: u32, image_width: f32, image_height: f32, - pressure: &PressureFrame, + pressure: &[[f32; 2]], ) -> Vec { 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 sin = tip.angle_rad.sin(); for row in 0..rows { for col in 0..cols { let index = (row * cols + col) as usize; - let [normalized, display_value] = - pressure.get(index).copied().unwrap_or([0.0, 0.0]); + let [normalized, display_value] = sample_pressure_at( + pressure, + tip_index * HAND_FINGER_SENSOR_CELLS + index, + index, + ); // 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]; @@ -1195,11 +1223,11 @@ fn build_hand_dot_instances( } fn build_hand_palm_dot_instances( - rows: u32, - cols: u32, + _rows: u32, + _cols: u32, image_width: f32, image_height: f32, - pressure: &PressureFrame, + pressure: &[[f32; 2]], ) -> Vec { let chip_dot_count: usize = HAND_PALM_CHIPS .iter() @@ -1207,7 +1235,7 @@ fn build_hand_palm_dot_instances( .sum(); 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 sin = chip.angle_rad.sin(); // 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 col in 0..chip.cols { - // Current live data is still the device pressure frame. Sample it by - // proportion so the palm's 5x14 and 11x4 layouts get coherent values. - let source_row = resample_index(row, chip.rows, rows); - let source_col = resample_index(col, chip.cols, cols); - let index = (source_row * cols + source_col) as usize; + let index = (row * chip.cols + col) as usize; + let offset = match chip_index { + 0 => HAND_PALM_HORIZONTAL_OFFSET, + _ => HAND_PALM_VERTICAL_OFFSET, + }; let [normalized, display_value] = - pressure.get(index).copied().unwrap_or([0.0, 0.0]); + sample_pressure_at(pressure, offset + index, index); let local_x = (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 } -fn resample_index(index: u32, source_count: u32, target_count: u32) -> u32 { - if source_count <= 1 || target_count <= 1 { - return 0; - } - - let mapped = index as f32 / (source_count - 1) as f32 * (target_count - 1) as f32; - mapped.round().clamp(0.0, (target_count - 1) as f32) as u32 +fn sample_pressure_at(pressure: &[[f32; 2]], index: usize, fallback_index: usize) -> [f32; 2] { + pressure + .get(index) + .or_else(|| pressure.get(fallback_index)) + .copied() + .unwrap_or([0.0, 0.0]) } fn build_glyph_instances( diff --git a/src/serial_core/codecs/hand_gateway.rs b/src/serial_core/codecs/hand_gateway.rs new file mode 100644 index 0000000..7edb93a --- /dev/null +++ b/src/serial_core/codecs/hand_gateway.rs @@ -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 { + 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, +} + +impl HandGatewayConfig { + pub fn new(nodes: Vec) -> 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, +} + +#[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, + pub nodes: Vec, + pub dts_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HandGatewayFrame { + DataRep(HandGatewayDataRepFrame), +} + +pub struct HandGatewayCodec { + buffer: Vec, + 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, 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 { + 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::>(); + + 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 { + 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 for HandGatewayCodec { + fn decode( + &mut self, + input: &[u8], + session_started_at: Instant, + ) -> Result, 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, CodecError> { + let _ = frame; + Err(CodecError::InvalidFrameType) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn data_response(config: u32, valid_config: u32, payload: &[u8]) -> Vec { + 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] + ); + } +} diff --git a/src/serial_core/codecs/mod.rs b/src/serial_core/codecs/mod.rs index 3de578d..4d94eaf 100644 --- a/src/serial_core/codecs/mod.rs +++ b/src/serial_core/codecs/mod.rs @@ -1 +1,2 @@ +pub mod hand_gateway; pub mod tactile_a; diff --git a/src/serial_core/mod.rs b/src/serial_core/mod.rs index 05940a0..5bf303c 100644 --- a/src/serial_core/mod.rs +++ b/src/serial_core/mod.rs @@ -2,5 +2,6 @@ pub mod codec; pub mod codecs; pub mod error; pub mod frame; +pub mod multi_dim_force; pub mod serial; pub mod utils; diff --git a/src/serial_core/multi_dim_force.rs b/src/serial_core/multi_dim_force.rs new file mode 100644 index 0000000..e0530e6 --- /dev/null +++ b/src/serial_core/multi_dim_force.rs @@ -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>, + + first_contact_cop_x: Option, + first_contact_cop_y: Option, + contact_initialized: bool, + + total_pressure_low_counter: usize, + + cop_init_x_buf: Vec, + cop_init_y_buf: Vec, + + post_init_frame_cnt: usize, + post_stable_cnt: usize, + post_refined_flag: bool, + post_cand_x: Option, + post_cand_y: Option, +} + +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 { + 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::(); + 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 { + 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 { + 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); + } +} diff --git a/src/serial_core/serial.rs b/src/serial_core/serial.rs index 96789b7..0a96873 100644 --- a/src/serial_core/serial.rs +++ b/src/serial_core/serial.rs @@ -1,4 +1,5 @@ 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::frame::TactileAFrame; use crossbeam_channel::{Receiver, Sender}; @@ -6,6 +7,7 @@ use std::io::{Read, Write}; use std::time::{Duration, Instant}; const POLL_INTERVAL_MS: u64 = 10; +const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70, 44]; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct SerialIoStats { @@ -13,9 +15,32 @@ pub struct SerialIoStats { 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. /// Sends decoded pressure matrix data (Vec) to the output channel. pub fn run_serial_loop( + port: &mut dyn ReadWrite, + rows: usize, + cols: usize, + protocol: SerialProtocol, + cancel_rx: &Receiver<()>, + sample_tx: &Sender>, + stats_tx: Option<&Sender>, +) { + match protocol { + SerialProtocol::TactileA => { + run_tactile_a_loop(port, rows, cols, cancel_rx, sample_tx, stats_tx) + } + SerialProtocol::HandGateway => run_hand_gateway_loop(port, cancel_rx, sample_tx, stats_tx), + } +} + +fn run_tactile_a_loop( port: &mut dyn ReadWrite, rows: usize, cols: usize, @@ -86,6 +111,97 @@ pub fn run_serial_loop( } } +fn run_hand_gateway_loop( + port: &mut dyn ReadWrite, + cancel_rx: &Receiver<()>, + sample_tx: &Sender>, + stats_tx: Option<&Sender>, +) { + 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::(), + // 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()); + 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::>() + .join(" ") +} + fn publish_stats(stats_tx: Option<&Sender>, stats: SerialIoStats) { if let Some(tx) = stats_tx { let _ = tx.try_send(stats); diff --git a/src/serial_core/utils.rs b/src/serial_core/utils.rs index 605ec58..f57005c 100644 --- a/src/serial_core/utils.rs +++ b/src/serial_core/utils.rs @@ -26,13 +26,17 @@ pub fn calc_crc8_itu(c: &[u8]) -> u8 { 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 { start_at.elapsed().as_millis() as u64 } #[cfg(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] fn test_crc8_itu() { @@ -51,4 +55,10 @@ mod test { let checksum = calc_crc8_smbus(req_vec.as_slice()); 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); + } } diff --git a/src/ui.rs b/src/ui.rs index f412734..e8d3b29 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,8 +2,9 @@ use eframe::egui; use crate::{ connection::{ConnectionManager, ConnectionState}, + force::HudSpatialForce, recording::Recorder, - serial_core::serial::SerialIoStats, + serial_core::serial::{SerialIoStats, SerialProtocol}, style::{ self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO, dim_text, group_frame, layout, panel_frame, tag_button, @@ -53,6 +54,22 @@ pub enum SerialMode { 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)] pub enum Parity { None, @@ -305,6 +322,8 @@ fn draw_connect_action_row( &config.selected_port, config.rows as u32, config.cols as u32, + config.mode.baud_rate(), + config.mode.protocol(), ); } } @@ -435,7 +454,13 @@ fn draw_connection_row( if is_connected { connection.disconnect(); } 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(), + ); } } @@ -646,7 +671,7 @@ fn baud_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) { .selected_text(config.baud_rate.to_string()) .show_ui(ui, |ui| { 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()); } @@ -672,16 +697,21 @@ pub fn draw_stats_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, force_history: &[f32], + spatial_force: Option, ) { draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| { ui.set_min_width(320.0); - draw_resultant_force_chart(ui, force_history); + draw_resultant_force_chart(ui, force_history, spatial_force); }); } const FORCE_CHART_MAX_N: f32 = 25.6; -fn draw_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) { +fn draw_resultant_force_chart( + ui: &mut egui::Ui, + values: &[f32], + spatial_force: Option, +) { let latest = values.last().copied().unwrap_or(0.0); let max = values.iter().copied().fold(0.0_f32, f32::max); let active_values = values.iter().copied().filter(|value| *value > 0.0); @@ -709,6 +739,26 @@ fn draw_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) { ui.add_space(6.0); paint_resultant_force_chart(ui, values); + + ui.add_space(8.0); + draw_spatial_force_readout(ui, spatial_force); + }); +} + +fn draw_spatial_force_readout(ui: &mut egui::Ui, spatial_force: Option) { + let Some(force) = spatial_force else { + ui.horizontal(|ui| { + ui.colored_label(dim_text(), "3D"); + ui.label(style::subtle_text("等待三维力数据")); + }); + return; + }; + + ui.horizontal_wrapped(|ui| { + ui.colored_label(ACCENT_BLUE, "3D"); + ui.label(style::value_text(format!("angle {:.0}°", force.angle_deg))); + ui.separator(); + ui.label(style::value_text(format!("mag {:.2}", force.magnitude))); }); } diff --git a/static/wgsl/shader.wgsl b/static/wgsl/shader.wgsl index f806908..bbd5168 100644 --- a/static/wgsl/shader.wgsl +++ b/static/wgsl/shader.wgsl @@ -497,13 +497,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. 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, - // while still letting pressure brighten the bead a little. - let cyan = vec3f(0.34, 0.86, 1.0); - let hot = sample_range_color(intensity); - let color = mix(cyan, hot, 0.22) * mix(0.82, 1.16, intensity); + // Match Finger mode's pressure gradient so each hand region remains readable. + let idle = vec3f(0.060, 0.250, 0.320); + let gradient = sample_range_color(intensity); + let color = mix(idle, gradient, smoothstep(0.0, 0.18, intensity)) + * mix(0.78, 1.18, intensity); return output_color(color, max(core, halo)); } @@ -590,13 +590,13 @@ fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVe fn fs_hand_palm_dot(in: DotVertexOutput) -> @location(0) vec4f { let intensity = saturate(in.intensity); 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 active_color = mix(vec3f(0.110, 0.620, 0.420), vec3f(0.460, 1.000, 0.210), smoothstep(0.08, 1.0, intensity)); - let color = mix(cold, active_color, smoothstep(0.02, 0.72, intensity)) - * (0.54 + intensity * 1.10) - + vec3f(0.28, 1.0, 0.36) * glow * 0.90; + let gradient = sample_range_color(intensity); + let color = mix(cold, gradient, smoothstep(0.0, 0.20, intensity)) + * (0.58 + intensity * 1.04) + + gradient * glow * 0.72; let alpha = max(pixel * (0.20 + intensity * 0.76), glow); return output_color(color, alpha);