Integrate hand gateway and spatial force rendering

This commit is contained in:
lenn
2026-06-29 18:55:42 +08:00
parent f30ebcf20b
commit d4f160af75
14 changed files with 1041 additions and 98 deletions

BIN
res/hand.glb Normal file

Binary file not shown.

View File

@@ -1,5 +1,6 @@
use crate::breakout::{BreakoutGame, control_from_matrix}; use crate::breakout::{BreakoutGame, control_from_matrix};
use crate::connection::ConnectionManager; use crate::connection::ConnectionManager;
use crate::force::{ForceEstimatorState, HudSpatialForce};
use crate::recording::Recorder; use crate::recording::Recorder;
use crate::render::{ActiveMode, FingerMode, HandGatewayMode}; use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout}; use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout};
@@ -7,7 +8,8 @@ use crate::ui::SerialMode;
use crate::{ use crate::{
matrix::{MATRIX_COLS, MATRIX_ROWS}, matrix::{MATRIX_COLS, MATRIX_ROWS},
render::{ render::{
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback, BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, PressureSamples,
WgpuBackgroundCallback,
}, },
ui::{ ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState, ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
@@ -18,17 +20,14 @@ use crate::{
use eframe::{egui, egui_wgpu}; use eframe::{egui, egui_wgpu};
use std::sync::Arc; use std::sync::Arc;
const DATA_LOG_EVERY_FRAMES: u64 = 30;
const SUMMARY_POINTS_PER_SERIES: usize = 42; const SUMMARY_POINTS_PER_SERIES: usize = 42;
const MIN_DISPLAY_FORCE_N: f32 = 0.1;
const MAX_DISPLAY_FORCE_N: f32 = 25.6;
pub struct EskinDesktopApp { pub struct EskinDesktopApp {
connect_panel: FloatingPanelState, connect_panel: FloatingPanelState,
connect_state: ConnectPanelState, connect_state: ConnectPanelState,
connection: Arc<ConnectionManager>, connection: Arc<ConnectionManager>,
pressure_matrix: PressureFrame, pressure_matrix: PressureFrame,
data_log_frame: u64, hand_pressure: PressureSamples,
scene_panel: FloatingPanelState, scene_panel: FloatingPanelState,
config_panel: FloatingPanelState, config_panel: FloatingPanelState,
config_state: ConfigPanelState, config_state: ConfigPanelState,
@@ -40,6 +39,8 @@ pub struct EskinDesktopApp {
matrix_config_panel: FloatingPanelState, matrix_config_panel: FloatingPanelState,
matrix_config: MatrixConfigState, matrix_config: MatrixConfigState,
signal_history: Vec<f32>, signal_history: Vec<f32>,
force_estimator: ForceEstimatorState,
latest_spatial_force: Option<HudSpatialForce>,
latest_raw_matrix: Vec<u32>, latest_raw_matrix: Vec<u32>,
latest_matrix_rows: u32, latest_matrix_rows: u32,
latest_matrix_cols: u32, latest_matrix_cols: u32,
@@ -77,7 +78,7 @@ impl EskinDesktopApp {
connect_state: ConnectPanelState::default(), connect_state: ConnectPanelState::default(),
connection: Arc::new(ConnectionManager::new()), connection: Arc::new(ConnectionManager::new()),
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT], pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
data_log_frame: 0, hand_pressure: Vec::new(),
scene_panel: FloatingPanelState::new( scene_panel: FloatingPanelState::new(
[layout::LEFT_X, layout::TOP_Y], [layout::LEFT_X, layout::TOP_Y],
[layout::LEFT_TAG_X, layout::TOP_Y], [layout::LEFT_TAG_X, layout::TOP_Y],
@@ -103,6 +104,8 @@ impl EskinDesktopApp {
), ),
matrix_config: MatrixConfigState::default(), matrix_config: MatrixConfigState::default(),
signal_history: Vec::with_capacity(128), signal_history: Vec::with_capacity(128),
force_estimator: ForceEstimatorState::new(),
latest_spatial_force: None,
latest_raw_matrix: Vec::new(), latest_raw_matrix: Vec::new(),
latest_matrix_rows: MATRIX_ROWS, latest_matrix_rows: MATRIX_ROWS,
latest_matrix_cols: MATRIX_COLS, latest_matrix_cols: MATRIX_COLS,
@@ -129,9 +132,40 @@ impl EskinDesktopApp {
width, width,
height, height,
pressure: self.pressure_matrix, pressure: self.pressure_matrix,
hand_pressure: self.hand_pressure.clone(),
active_mode: self.active_mode.clone(), active_mode: self.active_mode.clone(),
}, },
)); ));
self.paint_spatial_force_overlay(ui, rect);
}
fn paint_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) {
let Some(force) = self.latest_spatial_force else {
return;
};
let painter = ui.painter();
let center = rect.center();
let magnitude = force.magnitude.clamp(0.0, 1.8);
let length = 34.0 + magnitude * 54.0;
let angle = force.angle_deg.to_radians();
let direction = egui::vec2(angle.cos(), angle.sin());
let end = center + direction * length;
let side = egui::vec2(-direction.y, direction.x);
let color = egui::Color32::from_rgb(255, 196, 54);
let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58);
painter.line_segment([center, end], egui::Stroke::new(9.0, glow));
painter.line_segment([center, end], egui::Stroke::new(2.4, color));
painter.line_segment(
[end, end - direction * 14.0 + side * 7.0],
egui::Stroke::new(2.4, color),
);
painter.line_segment(
[end, end - direction * 14.0 - side * 7.0],
egui::Stroke::new(2.4, color),
);
painter.circle_filled(center, 4.2, color);
} }
fn draw_workspace(&mut self, ui: &mut egui::Ui) { fn draw_workspace(&mut self, ui: &mut egui::Ui) {
@@ -186,6 +220,7 @@ impl EskinDesktopApp {
sample.cols, sample.cols,
&mut self.pressure_matrix, &mut self.pressure_matrix,
); );
self.hand_pressure = normalize_pressure_samples(&sample.matrix);
self.latest_raw_matrix.clear(); self.latest_raw_matrix.clear();
self.latest_raw_matrix.extend_from_slice(&sample.matrix); self.latest_raw_matrix.extend_from_slice(&sample.matrix);
self.latest_matrix_rows = sample.rows; self.latest_matrix_rows = sample.rows;
@@ -194,27 +229,20 @@ impl EskinDesktopApp {
// Feed data to recorder // Feed data to recorder
self.recorder.add_frame(&sample.matrix); 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 let raw_total = sample
.matrix .matrix
.iter() .iter()
.fold(0_u64, |sum, value| sum + *value as u64) .fold(0_u64, |sum, value| sum + *value as u64)
.min(u32::MAX as u64) as u32; .min(u32::MAX as u64) as u32;
let force = raw_to_g1(raw_total).min(MAX_DISPLAY_FORCE_N); let force = raw_to_g1(raw_total).min(25.6);
let force = if force <= MIN_DISPLAY_FORCE_N { let force = if force <= 0.1 { 0.0 } else { force };
0.0
} else {
force
};
self.signal_history.push(force); self.signal_history.push(force);
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES { if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
self.signal_history.remove(0); self.signal_history.remove(0);
} }
self.data_log_frame += 1;
if self.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); 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( draw_export_panel(
ctx, ctx,
&mut self.export_panel, &mut self.export_panel,
@@ -416,9 +449,13 @@ impl EskinDesktopApp {
fn switch_mode(&mut self, next: SerialMode) { fn switch_mode(&mut self, next: SerialMode) {
self.connect_state.mode = next; self.connect_state.mode = next;
self.config_state.mode = next; self.config_state.mode = next;
self.config_state.baud_rate = next.baud_rate();
self.connection.disconnect(); self.connection.disconnect();
self.pressure_matrix.fill([0.0, 0.0]); self.pressure_matrix.fill([0.0, 0.0]);
self.data_log_frame = 0; self.hand_pressure.clear();
self.force_estimator.reset();
self.latest_spatial_force = None;
self.signal_history.clear();
self.active_mode = match next { self.active_mode = match next {
SerialMode::Finger => ActiveMode::Finger(FingerMode { 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::<Vec<_>>()
.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 { fn raw_to_g1(raw: u32) -> f32 {
const RAW: [u32; 12] = [ const RAW: [u32; 12] = [
0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444, 0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444,
@@ -465,7 +485,6 @@ fn raw_to_g1(raw: u32) -> f32 {
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0; return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
} }
// Keep the same lookup behavior as JE-Skin so both apps report the same force.
let mut left = 0usize; let mut left = 0usize;
let mut right = RAW.len() - 1; let mut right = RAW.len() - 1;
while left + 1 < right { while left + 1 < right {
@@ -534,9 +553,6 @@ fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
} }
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) { fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;
normalized.fill([0.0, 0.0]); normalized.fill([0.0, 0.0]);
let src_cols = cols.max(1); let src_cols = cols.max(1);
@@ -548,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 src_index = (row * src_cols + col) as usize;
let dst_index = (row * MATRIX_COLS + col) as usize; let dst_index = (row * MATRIX_COLS + col) as usize;
if let Some(value) = raw.get(src_index) { if let Some(value) = raw.get(src_index) {
let raw_value = *value as f32; normalized[dst_index] = normalize_pressure_value(*value);
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];
} }
} }
} }
} }
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 { impl eframe::App for EskinDesktopApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();

View File

@@ -4,7 +4,9 @@ use std::time::Duration;
use crossbeam_channel::{self, Receiver, Sender, TryRecvError}; use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
use crate::serial_core::serial::{SerialIoStats, SerialPortReadWrite, run_serial_loop}; use crate::serial_core::serial::{
SerialIoStats, SerialPortReadWrite, SerialProtocol, run_serial_loop,
};
/// Connection state visible to the UI. /// Connection state visible to the UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -28,6 +30,8 @@ struct Session {
handle: JoinHandle<()>, handle: JoinHandle<()>,
sample_rx: Receiver<Vec<i32>>, sample_rx: Receiver<Vec<i32>>,
stats_rx: Receiver<SerialIoStats>, stats_rx: Receiver<SerialIoStats>,
rows: u32,
cols: u32,
} }
/// Thread-safe connection manager that the UI and renderer can share. /// Thread-safe connection manager that the UI and renderer can share.
@@ -72,7 +76,14 @@ impl ConnectionManager {
} }
/// Connect to the given serial port and start streaming in a background thread. /// Connect to the given serial port and start streaming in a background thread.
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) { pub fn connect(
&self,
port_name: &str,
rows: u32,
cols: u32,
baud_rate: u32,
protocol: SerialProtocol,
) {
self.disconnect(); self.disconnect();
self.set_state(ConnectionState::Connecting); self.set_state(ConnectionState::Connecting);
@@ -90,6 +101,8 @@ impl ConnectionManager {
&port, &port,
rows, rows,
cols, cols,
baud_rate,
protocol,
&state, &state,
&cancel_rx, &cancel_rx,
&sample_tx, &sample_tx,
@@ -107,6 +120,8 @@ impl ConnectionManager {
handle, handle,
sample_rx, sample_rx,
stats_rx, stats_rx,
rows,
cols,
}); });
} }
@@ -135,10 +150,12 @@ impl ConnectionManager {
loop { loop {
match session.sample_rx.try_recv() { match session.sample_rx.try_recv() {
Ok(vals) => { Ok(vals) => {
let rows = 12u32;
let cols = 7u32;
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect(); let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
last = Some(PressureSample { matrix, rows, cols }); last = Some(PressureSample {
matrix,
rows: session.rows,
cols: session.cols,
});
} }
Err(TryRecvError::Empty) => break, Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break, Err(TryRecvError::Disconnected) => break,
@@ -163,13 +180,15 @@ fn run_device_loop(
port_name: &str, port_name: &str,
rows: u32, rows: u32,
cols: u32, cols: u32,
baud_rate: u32,
protocol: SerialProtocol,
state: &Arc<Mutex<ConnectionState>>, state: &Arc<Mutex<ConnectionState>>,
cancel_rx: &Receiver<()>, cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>, sample_tx: &Sender<Vec<i32>>,
stats_tx: &Sender<SerialIoStats>, stats_tx: &Sender<SerialIoStats>,
latest_sample: &Arc<Mutex<Option<PressureSample>>>, latest_sample: &Arc<Mutex<Option<PressureSample>>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let port = serialport::new(port_name, 921_600) let port = serialport::new(port_name, baud_rate)
.timeout(Duration::from_millis(100)) .timeout(Duration::from_millis(100))
.open()?; .open()?;
@@ -182,6 +201,7 @@ fn run_device_loop(
&mut rw, &mut rw,
rows as usize, rows as usize,
cols as usize, cols as usize,
protocol,
cancel_rx, cancel_rx,
sample_tx, sample_tx,
Some(stats_tx), Some(stats_tx),

49
src/force.rs Normal file
View File

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

View File

@@ -1,6 +1,9 @@
#![allow(dead_code)]
mod app; mod app;
mod breakout; mod breakout;
mod connection; mod connection;
mod force;
mod matrix; mod matrix;
mod model; mod model;
mod recording; mod recording;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,9 @@ use eframe::egui;
use crate::{ use crate::{
connection::{ConnectionManager, ConnectionState}, connection::{ConnectionManager, ConnectionState},
force::HudSpatialForce,
recording::Recorder, recording::Recorder,
serial_core::serial::SerialIoStats, serial_core::serial::{SerialIoStats, SerialProtocol},
style::{ style::{
self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO, self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO,
dim_text, group_frame, layout, panel_frame, tag_button, dim_text, group_frame, layout, panel_frame, tag_button,
@@ -53,6 +54,22 @@ pub enum SerialMode {
Hand, Hand,
} }
impl SerialMode {
pub fn baud_rate(self) -> u32 {
match self {
SerialMode::Finger => 921_600,
SerialMode::Hand => 1_152_000,
}
}
pub fn protocol(self) -> SerialProtocol {
match self {
SerialMode::Finger => SerialProtocol::TactileA,
SerialMode::Hand => SerialProtocol::HandGateway,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum Parity { pub enum Parity {
None, None,
@@ -305,6 +322,8 @@ fn draw_connect_action_row(
&config.selected_port, &config.selected_port,
config.rows as u32, config.rows as u32,
config.cols as u32, config.cols as u32,
config.mode.baud_rate(),
config.mode.protocol(),
); );
} }
} }
@@ -435,7 +454,13 @@ fn draw_connection_row(
if is_connected { if is_connected {
connection.disconnect(); connection.disconnect();
} else if !config.port.is_empty() { } else if !config.port.is_empty() {
connection.connect(&config.port, 12, 7); connection.connect(
&config.port,
12,
7,
config.mode.baud_rate(),
config.mode.protocol(),
);
} }
} }
@@ -646,7 +671,7 @@ fn baud_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
.selected_text(config.baud_rate.to_string()) .selected_text(config.baud_rate.to_string())
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
for baud_rate in [ for baud_rate in [
9_600, 19_200, 38_400, 57_600, 115_200, 230_400, 460_800, 921_600, 9_600, 19_200, 38_400, 57_600, 115_200, 230_400, 460_800, 921_600, 1_152_000,
] { ] {
ui.selectable_value(&mut config.baud_rate, baud_rate, baud_rate.to_string()); ui.selectable_value(&mut config.baud_rate, baud_rate, baud_rate.to_string());
} }
@@ -672,16 +697,21 @@ pub fn draw_stats_panel(
ctx: &egui::Context, ctx: &egui::Context,
panel: &mut FloatingPanelState, panel: &mut FloatingPanelState,
force_history: &[f32], force_history: &[f32],
spatial_force: Option<HudSpatialForce>,
) { ) {
draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| { draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| {
ui.set_min_width(320.0); 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; 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<HudSpatialForce>,
) {
let latest = values.last().copied().unwrap_or(0.0); let latest = values.last().copied().unwrap_or(0.0);
let max = values.iter().copied().fold(0.0_f32, f32::max); let max = values.iter().copied().fold(0.0_f32, f32::max);
let active_values = values.iter().copied().filter(|value| *value > 0.0); 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); ui.add_space(6.0);
paint_resultant_force_chart(ui, values); 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<HudSpatialForce>) {
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)));
}); });
} }

View File

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