Integrate hand fingertip force overlays

This commit is contained in:
lenn
2026-07-07 09:43:25 +08:00
parent c4bccc1747
commit a04b903e96
11 changed files with 409 additions and 164 deletions

View File

@@ -1,6 +1,6 @@
use crate::breakout::{BreakoutGame, control_from_matrix};
use crate::connection::ConnectionManager;
use crate::force::{ForceEstimatorState, HudSpatialForce};
use crate::connection::{ConnectionManager, ConnectionState};
use crate::force::{ForceEstimatorState, HAND_FINGERTIP_COUNT, HudSpatialForce};
use crate::recording::Recorder;
use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout};
@@ -25,7 +25,16 @@ use std::{
const SUMMARY_POINTS_PER_SERIES: usize = 42;
const HAND_FORCE_PANEL_COUNT: usize = 7;
const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44];
const HAND_IMAGE_SIZE_PX: [f32; 2] = [971.0, 1117.0];
const HAND_FINGERTIP_FORCE_ANCHORS_PX: [[f32; 2]; HAND_FINGERTIP_COUNT] = [
[265.0, 495.0],
[359.0, 260.0],
[482.0, 228.0],
[596.0, 265.0],
[693.0, 370.0],
];
const RATE_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
const DATA_IDLE_PANEL_EXIT_DELAY: Duration = Duration::from_secs(3);
pub struct EskinDesktopApp {
connect_panel: FloatingPanelState,
@@ -47,10 +56,12 @@ pub struct EskinDesktopApp {
hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT],
force_estimator: ForceEstimatorState,
latest_spatial_force: Option<HudSpatialForce>,
latest_hand_spatial_forces: [Option<HudSpatialForce>; HAND_FINGERTIP_COUNT],
spatial_force_panel_force: Option<HudSpatialForce>,
latest_raw_matrix: Vec<u32>,
latest_matrix_rows: u32,
latest_matrix_cols: u32,
last_sample_at: Option<Instant>,
breakout_visible: bool,
breakout_game: BreakoutGame,
live_rates: LiveRateMeters,
@@ -158,10 +169,12 @@ impl EskinDesktopApp {
hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)),
force_estimator: ForceEstimatorState::new(),
latest_spatial_force: None,
latest_hand_spatial_forces: [None; HAND_FINGERTIP_COUNT],
spatial_force_panel_force: None,
latest_raw_matrix: Vec::new(),
latest_matrix_rows: MATRIX_ROWS,
latest_matrix_cols: MATRIX_COLS,
last_sample_at: None,
breakout_visible: false,
breakout_game: BreakoutGame::default(),
live_rates: LiveRateMeters::new(),
@@ -185,35 +198,85 @@ impl EskinDesktopApp {
active_mode: self.active_mode.clone(),
},
));
self.paint_spatial_force_overlay(ui, rect);
}
fn paint_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) {
if !self.recent_sample_is_fresh() {
return;
}
if matches!(self.active_mode, ActiveMode::Hand(_)) {
self.paint_hand_spatial_force_overlay(ui, rect);
return;
}
let Some(force) = self.latest_spatial_force else {
return;
};
let 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);
paint_spatial_force_arrow(ui.painter(), center, force, 34.0, 54.0);
}
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 paint_hand_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) {
for (force, anchor_px) in self
.latest_hand_spatial_forces
.iter()
.zip(HAND_FINGERTIP_FORCE_ANCHORS_PX)
{
let Some(force) = force else {
continue;
};
let center = hand_image_pixel_to_screen(rect, anchor_px);
paint_spatial_force_arrow(ui.painter(), center, *force, 20.0, 34.0);
}
}
fn strongest_hand_spatial_force(&self) -> Option<HudSpatialForce> {
self.latest_hand_spatial_forces
.iter()
.flatten()
.copied()
.max_by(|a, b| a.magnitude.total_cmp(&b.magnitude))
}
fn analyze_spatial_force(&mut self, values: &[u32]) {
if matches!(self.config_state.mode, SerialMode::Hand) {
self.latest_hand_spatial_forces = self.force_estimator.analyze_fingertips(values);
self.latest_spatial_force = self.strongest_hand_spatial_force();
} else {
self.latest_spatial_force = self.force_estimator.analyze(values);
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
}
}
fn connection_has_live_data(&self) -> bool {
matches!(
self.connection.state(),
ConnectionState::Connected | ConnectionState::Streaming
)
}
fn recent_sample_is_fresh(&self) -> bool {
self.last_sample_at
.is_some_and(|sample_at| sample_at.elapsed() <= DATA_IDLE_PANEL_EXIT_DELAY)
}
fn hand_force_panels_visible(&self) -> bool {
self.connection_has_live_data() && self.recent_sample_is_fresh()
}
fn sync_disconnected_live_state(&mut self) {
if self.connection_has_live_data() {
return;
}
self.last_sample_at = None;
self.latest_spatial_force = None;
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
self.spatial_force_panel_force = None;
}
fn draw_workspace(&mut self, ui: &mut egui::Ui) {
@@ -264,6 +327,7 @@ impl EskinDesktopApp {
fn update_pressure_matrix(&mut self) {
if let Some(sample) = self.connection.take_latest_sample() {
self.last_sample_at = Some(Instant::now());
normalize_pressure_sample(
&sample.matrix,
sample.rows,
@@ -276,7 +340,7 @@ impl EskinDesktopApp {
self.latest_matrix_rows = sample.rows;
self.latest_matrix_cols = sample.cols;
self.latest_spatial_force = self.force_estimator.analyze(&sample.matrix);
self.analyze_spatial_force(&sample.matrix);
// Keep JE-Skin's summary path separate from the optional spatial-force vector.
let raw_total = sample
@@ -431,7 +495,11 @@ impl EskinDesktopApp {
}
self.stats_panel.visible = false;
draw_hand_force_panels(ctx, true, &self.hand_signal_histories);
draw_hand_force_panels(
ctx,
self.hand_force_panels_visible(),
&self.hand_signal_histories,
);
}
fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
@@ -518,8 +586,10 @@ impl EskinDesktopApp {
self.connection.disconnect();
self.pressure_matrix.fill([0.0, 0.0]);
self.hand_pressure.clear();
self.last_sample_at = None;
self.force_estimator.reset();
self.latest_spatial_force = None;
self.latest_hand_spatial_forces = [None; HAND_FINGERTIP_COUNT];
self.spatial_force_panel_force = None;
self.signal_history.clear();
self.hand_signal_histories
@@ -538,6 +608,60 @@ impl EskinDesktopApp {
}
}
fn hand_image_pixel_to_screen(rect: egui::Rect, point_px: [f32; 2]) -> egui::Pos2 {
let image_uv = egui::vec2(
point_px[0] / HAND_IMAGE_SIZE_PX[0].max(1.0),
point_px[1] / HAND_IMAGE_SIZE_PX[1].max(1.0),
);
let viewport_aspect = rect.width() / rect.height().max(1.0);
let image_aspect = HAND_IMAGE_SIZE_PX[0] / HAND_IMAGE_SIZE_PX[1].max(1.0);
let mut screen_uv = image_uv;
if viewport_aspect > image_aspect {
let image_width = image_aspect / viewport_aspect;
screen_uv.x = image_uv.x * image_width + (1.0 - image_width) * 0.5;
} else {
let image_height = viewport_aspect / image_aspect;
screen_uv.y = image_uv.y * image_height + (1.0 - image_height) * 0.5;
}
egui::pos2(
rect.left() + screen_uv.x * rect.width(),
rect.top() + screen_uv.y * rect.height(),
)
}
fn paint_spatial_force_arrow(
painter: &egui::Painter,
center: egui::Pos2,
force: HudSpatialForce,
base_length: f32,
magnitude_scale: f32,
) {
let magnitude = force.magnitude.clamp(0.0, 1.8);
let length = base_length + magnitude * magnitude_scale;
let angle = force.angle_deg.to_radians();
let direction = egui::vec2(angle.cos(), angle.sin());
let end = center + direction * length;
let side = egui::vec2(-direction.y, direction.x);
let head_length = (length * 0.24).clamp(8.0, 14.0);
let head_width = head_length * 0.5;
let color = egui::Color32::from_rgb(255, 196, 54);
let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58);
painter.line_segment([center, end], egui::Stroke::new(8.0, glow));
painter.line_segment([center, end], egui::Stroke::new(2.4, color));
painter.line_segment(
[end, end - direction * head_length + side * head_width],
egui::Stroke::new(2.4, color),
);
painter.line_segment(
[end, end - direction * head_length - side * head_width],
egui::Stroke::new(2.4, color),
);
painter.circle_filled(center, 3.8, color);
}
fn update_hand_signal_histories(
histories: &mut [Vec<f32>; HAND_FORCE_PANEL_COUNT],
raw_values: &[u32],
@@ -718,6 +842,7 @@ impl eframe::App for EskinDesktopApp {
self.live_rates.tick_render(stats.rx_frames);
self.update_pressure_matrix();
self.sync_disconnected_live_state();
self.draw_workspace(ui);
self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx, stats);