Add pressure visual modes and breakout game
This commit is contained in:
275
src/app.rs
275
src/app.rs
@@ -1,3 +1,4 @@
|
||||
use crate::breakout::{BreakoutGame, control_from_matrix};
|
||||
use crate::connection::ConnectionManager;
|
||||
use crate::recording::Recorder;
|
||||
use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
|
||||
@@ -18,6 +19,9 @@ 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,
|
||||
@@ -36,6 +40,13 @@ pub struct EskinDesktopApp {
|
||||
matrix_config_panel: FloatingPanelState,
|
||||
matrix_config: MatrixConfigState,
|
||||
signal_history: Vec<f32>,
|
||||
latest_raw_matrix: Vec<u32>,
|
||||
latest_matrix_rows: u32,
|
||||
latest_matrix_cols: u32,
|
||||
breakout_visible: bool,
|
||||
breakout_game: BreakoutGame,
|
||||
context_menu_open: bool,
|
||||
context_menu_pos: egui::Pos2,
|
||||
active_mode: ActiveMode,
|
||||
}
|
||||
|
||||
@@ -92,6 +103,13 @@ impl EskinDesktopApp {
|
||||
),
|
||||
matrix_config: MatrixConfigState::default(),
|
||||
signal_history: Vec::with_capacity(128),
|
||||
latest_raw_matrix: Vec::new(),
|
||||
latest_matrix_rows: MATRIX_ROWS,
|
||||
latest_matrix_cols: MATRIX_COLS,
|
||||
breakout_visible: false,
|
||||
breakout_game: BreakoutGame::default(),
|
||||
context_menu_open: false,
|
||||
context_menu_pos: egui::pos2(24.0, 48.0),
|
||||
active_mode: ActiveMode::Finger(FingerMode {
|
||||
rows: 12,
|
||||
cols: 7,
|
||||
@@ -101,10 +119,7 @@ impl EskinDesktopApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
|
||||
self.update_pressure_matrix();
|
||||
|
||||
let rect = ui.max_rect();
|
||||
fn draw_wgpu_background_rect(&self, ui: &mut egui::Ui, rect: egui::Rect) {
|
||||
let width = rect.width().max(1.0);
|
||||
let height = rect.height().max(1.0);
|
||||
|
||||
@@ -119,6 +134,50 @@ impl EskinDesktopApp {
|
||||
));
|
||||
}
|
||||
|
||||
fn draw_workspace(&mut self, ui: &mut egui::Ui) {
|
||||
if self.breakout_visible {
|
||||
self.draw_split_workspace(ui);
|
||||
} else {
|
||||
self.draw_wgpu_background_rect(ui, ui.max_rect());
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_split_workspace(&mut self, ui: &mut egui::Ui) {
|
||||
let screen = ui.max_rect();
|
||||
let content = egui::Rect::from_min_max(
|
||||
screen.left_top() + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT),
|
||||
screen.right_bottom(),
|
||||
);
|
||||
let gutter = 10.0;
|
||||
let half_width = (content.width() - gutter).max(1.0) * 0.5;
|
||||
let left = egui::Rect::from_min_size(content.min, egui::vec2(half_width, content.height()));
|
||||
let right = egui::Rect::from_min_max(
|
||||
egui::pos2(left.right() + gutter, content.top()),
|
||||
content.right_bottom(),
|
||||
);
|
||||
|
||||
ui.painter()
|
||||
.rect_filled(screen, egui::CornerRadius::ZERO, ONE_DARK_PRO.bg);
|
||||
paint_split_viewport_shell(ui, left, "DOTMATRIX", "压力矩阵 / WGPU");
|
||||
paint_split_viewport_shell(ui, right, "BREAKOUT", "压力控制打砖块");
|
||||
|
||||
let left_body = split_viewport_body(left);
|
||||
let right_body = split_viewport_body(right);
|
||||
self.draw_wgpu_background_rect(ui, left_body);
|
||||
|
||||
let breakout_control = control_from_matrix(
|
||||
&self.latest_raw_matrix,
|
||||
self.latest_matrix_rows,
|
||||
self.latest_matrix_cols,
|
||||
);
|
||||
self.breakout_game.draw_viewport(
|
||||
ui.ctx(),
|
||||
right_body,
|
||||
&mut self.breakout_visible,
|
||||
breakout_control,
|
||||
);
|
||||
}
|
||||
|
||||
fn update_pressure_matrix(&mut self) {
|
||||
if let Some(sample) = self.connection.take_latest_sample() {
|
||||
normalize_pressure_sample(
|
||||
@@ -127,14 +186,28 @@ impl EskinDesktopApp {
|
||||
sample.cols,
|
||||
&mut self.pressure_matrix,
|
||||
);
|
||||
self.latest_raw_matrix.clear();
|
||||
self.latest_raw_matrix.extend_from_slice(&sample.matrix);
|
||||
self.latest_matrix_rows = sample.rows;
|
||||
self.latest_matrix_cols = sample.cols;
|
||||
|
||||
// Feed data to recorder
|
||||
self.recorder.add_frame(&sample.matrix);
|
||||
|
||||
// Update signal history (sum of all pressure values)
|
||||
let total: f32 = sample.matrix.iter().map(|v| *v as f32).sum();
|
||||
self.signal_history.push(total);
|
||||
if self.signal_history.len() > 128 {
|
||||
// JE-Skin summary logic: sum all cells first, then convert raw summary to force.
|
||||
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
|
||||
};
|
||||
self.signal_history.push(force);
|
||||
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
|
||||
self.signal_history.remove(0);
|
||||
}
|
||||
|
||||
@@ -258,12 +331,15 @@ impl EskinDesktopApp {
|
||||
|
||||
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
||||
// draw_scene_panel(ctx, &mut self.scene_panel);
|
||||
if let Some(next_mode) =
|
||||
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state)
|
||||
{
|
||||
if let Some(next_mode) = draw_config_panel(
|
||||
ctx,
|
||||
&mut self.config_panel,
|
||||
&mut self.config_state,
|
||||
&self.connection,
|
||||
) {
|
||||
self.switch_mode(next_mode);
|
||||
}
|
||||
draw_stats_panel(ctx, &mut self.stats_panel);
|
||||
draw_stats_panel(ctx, &mut self.stats_panel, &self.signal_history);
|
||||
draw_export_panel(
|
||||
ctx,
|
||||
&mut self.export_panel,
|
||||
@@ -273,32 +349,68 @@ impl EskinDesktopApp {
|
||||
draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
|
||||
}
|
||||
|
||||
fn draw_panel_context_menu(&mut self, ui: &mut egui::Ui) {
|
||||
let response = ui.interact(
|
||||
ui.max_rect(),
|
||||
egui::Id::new("workspace_panel_context_menu"),
|
||||
egui::Sense::click(),
|
||||
);
|
||||
|
||||
response.context_menu(|ui| {
|
||||
ui.label("显示面板");
|
||||
ui.separator();
|
||||
|
||||
panel_restore_item(ui, "配置", &mut self.config_panel);
|
||||
panel_restore_item(ui, "录制", &mut self.export_panel);
|
||||
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
|
||||
panel_restore_item(ui, "统计", &mut self.stats_panel);
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button("全部显示").clicked() {
|
||||
self.config_panel.visible = true;
|
||||
self.export_panel.visible = true;
|
||||
self.matrix_config_panel.visible = true;
|
||||
self.stats_panel.visible = true;
|
||||
ui.close();
|
||||
}
|
||||
fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
|
||||
let (secondary_clicked, pointer_pos, escape_pressed) = ctx.input(|input| {
|
||||
(
|
||||
input.pointer.secondary_clicked(),
|
||||
input.pointer.interact_pos(),
|
||||
input.key_pressed(egui::Key::Escape),
|
||||
)
|
||||
});
|
||||
|
||||
if secondary_clicked {
|
||||
self.context_menu_open = true;
|
||||
self.context_menu_pos = pointer_pos.unwrap_or(self.context_menu_pos);
|
||||
}
|
||||
if escape_pressed {
|
||||
self.context_menu_open = false;
|
||||
}
|
||||
if !self.context_menu_open {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut close_menu = false;
|
||||
let response = egui::Area::new(egui::Id::new("workspace_context_menu_area"))
|
||||
.fixed_pos(self.context_menu_pos)
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(132.0);
|
||||
ui.label("显示面板");
|
||||
ui.separator();
|
||||
|
||||
panel_restore_item(ui, "配置", &mut self.config_panel);
|
||||
panel_restore_item(ui, "录制", &mut self.export_panel);
|
||||
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
|
||||
panel_restore_item(ui, "统计", &mut self.stats_panel);
|
||||
if ui.button("打砖块").clicked() {
|
||||
self.breakout_visible = true;
|
||||
close_menu = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button("全部显示").clicked() {
|
||||
self.config_panel.visible = true;
|
||||
self.export_panel.visible = true;
|
||||
self.matrix_config_panel.visible = true;
|
||||
self.stats_panel.visible = true;
|
||||
self.breakout_visible = true;
|
||||
close_menu = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let clicked_outside = ctx.input(|input| {
|
||||
input.pointer.primary_clicked()
|
||||
&& input
|
||||
.pointer
|
||||
.interact_pos()
|
||||
.is_some_and(|pos| !response.response.rect.contains(pos))
|
||||
});
|
||||
if close_menu || clicked_outside {
|
||||
self.context_menu_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_mode(&mut self, next: SerialMode) {
|
||||
@@ -337,6 +449,90 @@ fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) {
|
||||
);
|
||||
}
|
||||
|
||||
fn raw_to_g1(raw: u32) -> f32 {
|
||||
const RAW: [u32; 12] = [
|
||||
0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444,
|
||||
];
|
||||
const FORCE_CENTI_N: [f32; 12] = [
|
||||
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0, 2557.0,
|
||||
];
|
||||
|
||||
if raw <= RAW[0] {
|
||||
return FORCE_CENTI_N[0] / 100.0;
|
||||
}
|
||||
|
||||
if raw >= RAW[RAW.len() - 1] {
|
||||
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let mid = (left + right) / 2;
|
||||
if RAW[mid] <= raw {
|
||||
left = mid;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
|
||||
let raw_left = RAW[left] as f32;
|
||||
let raw_right = RAW[right] as f32;
|
||||
let span = (raw_right - raw_left).max(1.0);
|
||||
let ratio = (raw as f32 - raw_left) / span;
|
||||
|
||||
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||||
}
|
||||
|
||||
fn paint_split_viewport_shell(
|
||||
ui: &egui::Ui,
|
||||
rect: egui::Rect,
|
||||
title: &'static str,
|
||||
subtitle: &'static str,
|
||||
) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
egui::CornerRadius::same(8),
|
||||
egui::Color32::from_rgb(11, 17, 23),
|
||||
);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
egui::CornerRadius::same(8),
|
||||
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
painter.line_segment(
|
||||
[
|
||||
rect.left_top() + egui::vec2(12.0, 34.0),
|
||||
rect.right_top() + egui::vec2(-12.0, 34.0),
|
||||
],
|
||||
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||
);
|
||||
painter.text(
|
||||
rect.left_top() + egui::vec2(14.0, 16.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
title,
|
||||
egui::FontId::monospace(13.0),
|
||||
ONE_DARK_PRO.accent,
|
||||
);
|
||||
painter.text(
|
||||
rect.left_top() + egui::vec2(122.0, 16.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
subtitle,
|
||||
egui::FontId::proportional(12.0),
|
||||
ONE_DARK_PRO.text_dim,
|
||||
);
|
||||
}
|
||||
|
||||
fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
|
||||
egui::Rect::from_min_max(
|
||||
rect.left_top() + egui::vec2(10.0, 44.0),
|
||||
rect.right_bottom() - egui::vec2(10.0, 10.0),
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -369,10 +565,11 @@ impl eframe::App for EskinDesktopApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
self.draw_wgpu_background(ui);
|
||||
self.draw_panel_context_menu(ui);
|
||||
self.update_pressure_matrix();
|
||||
self.draw_workspace(ui);
|
||||
self.draw_title_bar(ui, frame);
|
||||
self.draw_floating_panels(&ctx);
|
||||
self.draw_panel_context_menu(&ctx);
|
||||
|
||||
// Keep repainting while the wgpu background is a realtime viewport.
|
||||
ctx.request_repaint();
|
||||
|
||||
643
src/breakout.rs
Normal file
643
src/breakout.rs
Normal file
@@ -0,0 +1,643 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::style::{self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, ONE_DARK_PRO};
|
||||
|
||||
const BRICK_ROWS: usize = 7;
|
||||
const BRICK_COLS: usize = 12;
|
||||
const PADDLE_W: f32 = 0.24;
|
||||
const PADDLE_H: f32 = 0.030;
|
||||
const PADDLE_Y: f32 = 0.88;
|
||||
const PADDLE_SPEED: f32 = 0.92;
|
||||
const BALL_RADIUS: f32 = 0.010;
|
||||
const BALL_SPEED: f32 = 0.52;
|
||||
const MAX_BALL_SPEED: f32 = 0.86;
|
||||
const PRESSURE_RANGE_MIN: f32 = 0.0;
|
||||
const PRESSURE_RANGE_MAX: f32 = 7000.0;
|
||||
const PAUSE_COOLDOWN: f64 = 0.64;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum BreakoutPhase {
|
||||
Idle,
|
||||
Running,
|
||||
Paused,
|
||||
Over,
|
||||
}
|
||||
|
||||
struct Brick {
|
||||
rect: egui::Rect,
|
||||
alive: bool,
|
||||
flash: f32,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct BreakoutControl {
|
||||
pub axis: f32,
|
||||
pub top_force: f32,
|
||||
pub left_force: f32,
|
||||
pub right_force: f32,
|
||||
}
|
||||
|
||||
pub struct BreakoutGame {
|
||||
phase: BreakoutPhase,
|
||||
bricks: Vec<Brick>,
|
||||
paddle_x: f32,
|
||||
ball_pos: egui::Pos2,
|
||||
ball_vel: egui::Vec2,
|
||||
score: u32,
|
||||
combo: u32,
|
||||
lives: u32,
|
||||
level: u32,
|
||||
last_time: Option<f64>,
|
||||
previous_pause_gesture: bool,
|
||||
pause_locked_until: f64,
|
||||
}
|
||||
|
||||
impl Default for BreakoutGame {
|
||||
fn default() -> Self {
|
||||
let mut game = Self {
|
||||
phase: BreakoutPhase::Idle,
|
||||
bricks: Vec::new(),
|
||||
paddle_x: 0.5,
|
||||
ball_pos: egui::pos2(0.5, PADDLE_Y - PADDLE_H - BALL_RADIUS),
|
||||
ball_vel: egui::vec2(0.0, 0.0),
|
||||
score: 0,
|
||||
combo: 0,
|
||||
lives: 3,
|
||||
level: 1,
|
||||
last_time: None,
|
||||
previous_pause_gesture: false,
|
||||
pause_locked_until: 0.0,
|
||||
};
|
||||
game.rebuild_bricks();
|
||||
game
|
||||
}
|
||||
}
|
||||
|
||||
impl BreakoutGame {
|
||||
pub fn draw_viewport(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
rect: egui::Rect,
|
||||
visible: &mut bool,
|
||||
control: BreakoutControl,
|
||||
) {
|
||||
if !*visible {
|
||||
self.last_time = None;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.request_repaint();
|
||||
let now = ctx.input(|input| input.time);
|
||||
let dt = self
|
||||
.last_time
|
||||
.map(|last| (now - last) as f32)
|
||||
.unwrap_or(1.0 / 60.0)
|
||||
.clamp(0.0, 1.0 / 30.0);
|
||||
self.last_time = Some(now);
|
||||
|
||||
let mut close_requested = false;
|
||||
egui::Area::new(egui::Id::new("breakout_game_viewport"))
|
||||
.fixed_pos(rect.min)
|
||||
.order(egui::Order::Middle)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_min_size(rect.size());
|
||||
ui.set_max_size(rect.size());
|
||||
style::group_frame().show(ui, |ui| {
|
||||
ui.set_min_size(rect.size() - egui::vec2(20.0, 16.0));
|
||||
if self.draw_contents(ui, control, dt, now, true) {
|
||||
close_requested = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if close_requested {
|
||||
*visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_contents(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
control: BreakoutControl,
|
||||
dt: f32,
|
||||
now: f64,
|
||||
embedded: bool,
|
||||
) -> bool {
|
||||
let mut close_requested = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(
|
||||
ACCENT_BLUE,
|
||||
egui::RichText::new("NEON BREAKOUT").monospace().size(13.0),
|
||||
);
|
||||
ui.label(style::subtle_text("压力矩阵控制 / 左右分区驱动"));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if embedded && ui.add(style::tag_button("退出分屏")).clicked() {
|
||||
close_requested = true;
|
||||
}
|
||||
if ui.add(style::tag_button("重开")).clicked() {
|
||||
self.reset();
|
||||
self.start();
|
||||
}
|
||||
if ui.add(style::tag_button("暂停")).clicked() {
|
||||
self.toggle_pause();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(5.0);
|
||||
ui.horizontal(|ui| {
|
||||
status_chip(ui, "状态", self.status_text(), status_color(self.phase));
|
||||
status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN);
|
||||
status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE);
|
||||
status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED);
|
||||
status_chip(ui, "关卡", self.level.to_string(), ACCENT_BLUE);
|
||||
});
|
||||
|
||||
ui.add_space(7.0);
|
||||
let available = ui.available_size();
|
||||
let game_size = egui::vec2(available.x.max(500.0), (available.y - 6.0).max(330.0));
|
||||
let (rect, response) = ui.allocate_exact_size(game_size, egui::Sense::click());
|
||||
if response.clicked() && self.phase != BreakoutPhase::Running {
|
||||
self.start();
|
||||
}
|
||||
|
||||
let keyboard_axis = ui.input(|input| {
|
||||
(input.key_down(egui::Key::ArrowRight) as i32
|
||||
- input.key_down(egui::Key::ArrowLeft) as i32) as f32
|
||||
});
|
||||
if ui.input(|input| input.key_pressed(egui::Key::Space)) {
|
||||
self.start();
|
||||
}
|
||||
if ui.input(|input| input.key_pressed(egui::Key::P)) {
|
||||
self.toggle_pause();
|
||||
}
|
||||
|
||||
let axis = if keyboard_axis.abs() > 0.0 {
|
||||
keyboard_axis
|
||||
} else {
|
||||
control.axis
|
||||
};
|
||||
self.handle_pressure_gesture(control.top_force, now);
|
||||
self.update(dt, axis);
|
||||
self.paint(ui, rect, control);
|
||||
close_requested
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.phase = BreakoutPhase::Idle;
|
||||
self.paddle_x = 0.5;
|
||||
self.ball_pos = egui::pos2(0.5, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||
self.ball_vel = egui::Vec2::ZERO;
|
||||
self.score = 0;
|
||||
self.combo = 0;
|
||||
self.lives = 3;
|
||||
self.level = 1;
|
||||
self.rebuild_bricks();
|
||||
}
|
||||
|
||||
fn start(&mut self) {
|
||||
if self.phase == BreakoutPhase::Over {
|
||||
self.reset();
|
||||
}
|
||||
if self.phase != BreakoutPhase::Running {
|
||||
self.phase = BreakoutPhase::Running;
|
||||
if self.ball_vel == egui::Vec2::ZERO {
|
||||
self.launch_ball();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_pause(&mut self) {
|
||||
self.phase = match self.phase {
|
||||
BreakoutPhase::Running => BreakoutPhase::Paused,
|
||||
BreakoutPhase::Paused => BreakoutPhase::Running,
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
|
||||
fn handle_pressure_gesture(&mut self, top_force: f32, now: f64) {
|
||||
let threshold = pressure_pause_threshold();
|
||||
let active = top_force >= threshold;
|
||||
if active && !self.previous_pause_gesture && now >= self.pause_locked_until {
|
||||
if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Over {
|
||||
self.start();
|
||||
} else {
|
||||
self.toggle_pause();
|
||||
}
|
||||
self.pause_locked_until = now + PAUSE_COOLDOWN;
|
||||
}
|
||||
self.previous_pause_gesture = active;
|
||||
}
|
||||
|
||||
fn update(&mut self, dt: f32, axis: f32) {
|
||||
for brick in &mut self.bricks {
|
||||
brick.flash = (brick.flash - dt * 2.4).max(0.0);
|
||||
}
|
||||
|
||||
if self.phase != BreakoutPhase::Running {
|
||||
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||
return;
|
||||
}
|
||||
|
||||
self.paddle_x = (self.paddle_x + axis.clamp(-1.0, 1.0) * PADDLE_SPEED * dt)
|
||||
.clamp(PADDLE_W * 0.5, 1.0 - PADDLE_W * 0.5);
|
||||
|
||||
self.ball_pos += self.ball_vel * dt;
|
||||
self.resolve_wall_collision();
|
||||
self.resolve_paddle_collision();
|
||||
self.resolve_brick_collision();
|
||||
}
|
||||
|
||||
fn launch_ball(&mut self) {
|
||||
let direction = if self.level % 2 == 0 { -0.24 } else { 0.24 };
|
||||
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||
self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED;
|
||||
}
|
||||
|
||||
fn resolve_wall_collision(&mut self) {
|
||||
if self.ball_pos.x - BALL_RADIUS <= 0.0 {
|
||||
self.ball_pos.x = BALL_RADIUS;
|
||||
self.ball_vel.x = self.ball_vel.x.abs();
|
||||
} else if self.ball_pos.x + BALL_RADIUS >= 1.0 {
|
||||
self.ball_pos.x = 1.0 - BALL_RADIUS;
|
||||
self.ball_vel.x = -self.ball_vel.x.abs();
|
||||
}
|
||||
|
||||
if self.ball_pos.y - BALL_RADIUS <= 0.0 {
|
||||
self.ball_pos.y = BALL_RADIUS;
|
||||
self.ball_vel.y = self.ball_vel.y.abs();
|
||||
}
|
||||
|
||||
if self.ball_pos.y - BALL_RADIUS > 1.0 {
|
||||
self.lives = self.lives.saturating_sub(1);
|
||||
self.combo = 0;
|
||||
if self.lives == 0 {
|
||||
self.phase = BreakoutPhase::Over;
|
||||
self.ball_vel = egui::Vec2::ZERO;
|
||||
} else {
|
||||
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||
self.launch_ball();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_paddle_collision(&mut self) {
|
||||
if self.ball_vel.y <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let paddle = paddle_rect(self.paddle_x);
|
||||
if !circle_hits_rect(self.ball_pos, BALL_RADIUS, paddle) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.ball_pos.y = paddle.top() - BALL_RADIUS;
|
||||
let offset = ((self.ball_pos.x - self.paddle_x) / (PADDLE_W * 0.5)).clamp(-1.0, 1.0);
|
||||
let speed = self.ball_vel.length().clamp(BALL_SPEED, MAX_BALL_SPEED);
|
||||
self.ball_vel = egui::vec2(offset * 0.82, -1.0).normalized() * speed;
|
||||
self.combo = 0;
|
||||
}
|
||||
|
||||
fn resolve_brick_collision(&mut self) {
|
||||
for brick in &mut self.bricks {
|
||||
if !brick.alive || !circle_hits_rect(self.ball_pos, BALL_RADIUS, brick.rect) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let overlap_x = (self.ball_pos.x + BALL_RADIUS - brick.rect.left())
|
||||
.min(brick.rect.right() - (self.ball_pos.x - BALL_RADIUS));
|
||||
let overlap_y = (self.ball_pos.y + BALL_RADIUS - brick.rect.top())
|
||||
.min(brick.rect.bottom() - (self.ball_pos.y - BALL_RADIUS));
|
||||
if overlap_x < overlap_y {
|
||||
self.ball_vel.x *= -1.0;
|
||||
} else {
|
||||
self.ball_vel.y *= -1.0;
|
||||
}
|
||||
|
||||
brick.alive = false;
|
||||
brick.flash = 1.0;
|
||||
self.combo += 1;
|
||||
self.score += 40 + self.combo * 5;
|
||||
let speed = (self.ball_vel.length() * 1.014).min(MAX_BALL_SPEED);
|
||||
self.ball_vel = self.ball_vel.normalized() * speed;
|
||||
break;
|
||||
}
|
||||
|
||||
if self.bricks.iter().all(|brick| !brick.alive) {
|
||||
self.level += 1;
|
||||
self.rebuild_bricks();
|
||||
self.launch_ball();
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_bricks(&mut self) {
|
||||
self.bricks.clear();
|
||||
let gap_x = 0.012;
|
||||
let gap_y = 0.017;
|
||||
let left = 0.075;
|
||||
let top = 0.10;
|
||||
let total_w = 0.85;
|
||||
let brick_w = (total_w - gap_x * (BRICK_COLS - 1) as f32) / BRICK_COLS as f32;
|
||||
let brick_h = 0.040;
|
||||
|
||||
for row in 0..BRICK_ROWS {
|
||||
for col in 0..BRICK_COLS {
|
||||
let x = left + col as f32 * (brick_w + gap_x);
|
||||
let y = top + row as f32 * (brick_h + gap_y);
|
||||
self.bricks.push(Brick {
|
||||
rect: egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(brick_w, brick_h)),
|
||||
alive: true,
|
||||
flash: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status_text(&self) -> &'static str {
|
||||
match self.phase {
|
||||
BreakoutPhase::Idle => "待机",
|
||||
BreakoutPhase::Running => "运行",
|
||||
BreakoutPhase::Paused => "暂停",
|
||||
BreakoutPhase::Over => "结束",
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, ui: &egui::Ui, rect: egui::Rect, control: BreakoutControl) {
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
egui::CornerRadius::same(6),
|
||||
egui::Color32::from_rgb(8, 14, 19),
|
||||
);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
egui::CornerRadius::same(6),
|
||||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.accent, 110)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
|
||||
paint_arena_grid(&painter, rect);
|
||||
for (index, brick) in self.bricks.iter().enumerate() {
|
||||
paint_brick(&painter, rect, brick, index);
|
||||
}
|
||||
|
||||
paint_paddle(&painter, rect, self.paddle_x);
|
||||
paint_ball(&painter, rect, self.ball_pos);
|
||||
paint_control_meter(&painter, rect, control);
|
||||
|
||||
if self.phase == BreakoutPhase::Idle
|
||||
|| self.phase == BreakoutPhase::Paused
|
||||
|| self.phase == BreakoutPhase::Over
|
||||
{
|
||||
paint_center_overlay(&painter, rect, self.phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl {
|
||||
if raw.is_empty() {
|
||||
return BreakoutControl::default();
|
||||
}
|
||||
|
||||
let rows = rows.max(1) as usize;
|
||||
let cols = cols.max(1) as usize;
|
||||
let sample_rows = rows.min(2);
|
||||
let sample_cols = cols.min(2);
|
||||
let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 {
|
||||
let mut sum = 0.0;
|
||||
let mut count = 0.0;
|
||||
for row in row_start..row_end {
|
||||
for col in col_start..col_end {
|
||||
let index = row * cols + col;
|
||||
if let Some(value) = raw.get(index) {
|
||||
sum += *value as f32;
|
||||
count += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 0.0 { sum / count } else { 0.0 }
|
||||
};
|
||||
|
||||
let tl = avg(0, sample_rows, 0, sample_cols);
|
||||
let tr = avg(0, sample_rows, cols.saturating_sub(sample_cols), cols);
|
||||
let bl = avg(rows.saturating_sub(sample_rows), rows, 0, sample_cols);
|
||||
let br = avg(
|
||||
rows.saturating_sub(sample_rows),
|
||||
rows,
|
||||
cols.saturating_sub(sample_cols),
|
||||
cols,
|
||||
);
|
||||
|
||||
let left_force = tl + bl;
|
||||
let right_force = tr + br;
|
||||
let top_force = tl + tr;
|
||||
let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22);
|
||||
let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0);
|
||||
let axis = if raw_axis.abs() < 0.045 {
|
||||
0.0
|
||||
} else {
|
||||
raw_axis
|
||||
};
|
||||
|
||||
BreakoutControl {
|
||||
axis,
|
||||
top_force,
|
||||
left_force,
|
||||
right_force,
|
||||
}
|
||||
}
|
||||
|
||||
fn pressure_pause_threshold() -> f32 {
|
||||
420.0_f32.max(((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN).max(1000.0) * 0.07).round())
|
||||
}
|
||||
|
||||
fn paddle_rect(paddle_x: f32) -> egui::Rect {
|
||||
egui::Rect::from_center_size(
|
||||
egui::pos2(paddle_x, PADDLE_Y),
|
||||
egui::vec2(PADDLE_W, PADDLE_H),
|
||||
)
|
||||
}
|
||||
|
||||
fn circle_hits_rect(center: egui::Pos2, radius: f32, rect: egui::Rect) -> bool {
|
||||
let closest_x = center.x.clamp(rect.left(), rect.right());
|
||||
let closest_y = center.y.clamp(rect.top(), rect.bottom());
|
||||
let dx = center.x - closest_x;
|
||||
let dy = center.y - closest_y;
|
||||
dx * dx + dy * dy <= radius * radius
|
||||
}
|
||||
|
||||
fn status_chip(ui: &mut egui::Ui, label: &'static str, value: impl ToString, color: egui::Color32) {
|
||||
egui::Frame::new()
|
||||
.fill(color_alpha(ONE_DARK_PRO.panel_deep, 190))
|
||||
.stroke(egui::Stroke::new(1.0, color_alpha(color, 92)))
|
||||
.corner_radius(egui::CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::symmetric(8, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(ONE_DARK_PRO.text_subtle, label);
|
||||
ui.colored_label(color, value.to_string());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn status_color(phase: BreakoutPhase) -> egui::Color32 {
|
||||
match phase {
|
||||
BreakoutPhase::Idle => ONE_DARK_PRO.text_dim,
|
||||
BreakoutPhase::Running => ACCENT_GREEN,
|
||||
BreakoutPhase::Paused => ACCENT_ORANGE,
|
||||
BreakoutPhase::Over => ACCENT_RED,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_screen(rect: egui::Rect, point: egui::Pos2) -> egui::Pos2 {
|
||||
egui::pos2(
|
||||
rect.left() + point.x * rect.width(),
|
||||
rect.top() + point.y * rect.height(),
|
||||
)
|
||||
}
|
||||
|
||||
fn to_screen_rect(rect: egui::Rect, local: egui::Rect) -> egui::Rect {
|
||||
egui::Rect::from_min_max(
|
||||
rect.left_top() + local.min.to_vec2() * rect.size(),
|
||||
rect.left_top() + local.max.to_vec2() * rect.size(),
|
||||
)
|
||||
}
|
||||
|
||||
fn paint_arena_grid(painter: &egui::Painter, rect: egui::Rect) {
|
||||
// 游戏背景保持纯净,避免网格和外框干扰砖块本身的色块节奏。
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
egui::CornerRadius::same(6),
|
||||
egui::Color32::from_rgb(2, 8, 10),
|
||||
);
|
||||
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
egui::CornerRadius::same(6),
|
||||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.accent, 70)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
|
||||
fn paint_brick(painter: &egui::Painter, arena: egui::Rect, brick: &Brick, index: usize) {
|
||||
let rect = to_screen_rect(arena, brick.rect);
|
||||
if brick.alive {
|
||||
let row = index / BRICK_COLS;
|
||||
let col = index % BRICK_COLS;
|
||||
// 参考示例图:纯色矩形砖块,黑色间隔由砖块之间的空隙自然露出。
|
||||
painter.rect_filled(rect, egui::CornerRadius::ZERO, brick_color(row, col));
|
||||
} else if brick.flash > 0.0 {
|
||||
let alpha = (brick.flash * 150.0) as u8;
|
||||
painter.rect_stroke(
|
||||
rect.expand(4.0 * brick.flash),
|
||||
egui::CornerRadius::same(4),
|
||||
egui::Stroke::new(1.4, color_alpha(ONE_DARK_PRO.accent_hot, alpha)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_paddle(painter: &egui::Painter, arena: egui::Rect, paddle_x: f32) {
|
||||
let rect = to_screen_rect(arena, paddle_rect(paddle_x));
|
||||
// 托盘也改成纯色块,和砖块保持同一种视觉语言。
|
||||
painter.rect_filled(rect, egui::CornerRadius::ZERO, ACCENT_BLUE);
|
||||
}
|
||||
|
||||
fn paint_ball(painter: &egui::Painter, arena: egui::Rect, ball_pos: egui::Pos2) {
|
||||
let center = to_screen(arena, ball_pos);
|
||||
let radius = BALL_RADIUS * arena.width().min(arena.height());
|
||||
// 球保留为一个普通实心圆,避免额外高光和光晕。
|
||||
painter.circle_filled(center, radius, egui::Color32::from_rgb(241, 207, 70));
|
||||
}
|
||||
|
||||
fn paint_control_meter(painter: &egui::Painter, arena: egui::Rect, control: BreakoutControl) {
|
||||
let meter_width = arena.width().mul_add(0.30, 0.0).clamp(180.0, 240.0);
|
||||
let meter = egui::Rect::from_min_size(
|
||||
arena.left_bottom() + egui::vec2(18.0, -34.0),
|
||||
egui::vec2(meter_width, 18.0),
|
||||
);
|
||||
painter.rect_filled(
|
||||
meter,
|
||||
egui::CornerRadius::same(4),
|
||||
color_alpha(ONE_DARK_PRO.panel_deep, 220),
|
||||
);
|
||||
painter.rect_stroke(
|
||||
meter,
|
||||
egui::CornerRadius::same(4),
|
||||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border, 120)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
let center_x = meter.center().x;
|
||||
painter.line_segment(
|
||||
[
|
||||
egui::pos2(center_x, meter.top()),
|
||||
egui::pos2(center_x, meter.bottom()),
|
||||
],
|
||||
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||
);
|
||||
let marker_x = center_x + control.axis.clamp(-1.0, 1.0) * meter.width() * 0.45;
|
||||
painter.circle_filled(
|
||||
egui::pos2(marker_x, meter.center().y),
|
||||
5.0,
|
||||
color_alpha(ACCENT_GREEN, 210),
|
||||
);
|
||||
painter.circle_filled(
|
||||
egui::pos2(marker_x, meter.center().y),
|
||||
2.3,
|
||||
color_alpha(egui::Color32::WHITE, 150),
|
||||
);
|
||||
painter.text(
|
||||
meter.right_center() + egui::vec2(10.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
format!(
|
||||
"L {:>4.0} R {:>4.0} T {:>4.0}",
|
||||
control.left_force, control.right_force, control.top_force
|
||||
),
|
||||
egui::FontId::monospace(10.0),
|
||||
ONE_DARK_PRO.text_dim,
|
||||
);
|
||||
}
|
||||
|
||||
fn paint_center_overlay(painter: &egui::Painter, rect: egui::Rect, phase: BreakoutPhase) {
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
egui::CornerRadius::same(6),
|
||||
color_alpha(egui::Color32::BLACK, 82),
|
||||
);
|
||||
let (title, detail) = match phase {
|
||||
BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"),
|
||||
BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"),
|
||||
BreakoutPhase::Over => ("游戏结束", "点击或空格重开"),
|
||||
BreakoutPhase::Running => ("", ""),
|
||||
};
|
||||
painter.text(
|
||||
rect.center() - egui::vec2(0.0, 10.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
title,
|
||||
egui::FontId::proportional(24.0),
|
||||
ONE_DARK_PRO.text,
|
||||
);
|
||||
painter.text(
|
||||
rect.center() + egui::vec2(0.0, 20.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
detail,
|
||||
egui::FontId::proportional(13.0),
|
||||
ONE_DARK_PRO.text_dim,
|
||||
);
|
||||
}
|
||||
|
||||
fn brick_color(row: usize, col: usize) -> egui::Color32 {
|
||||
const PALETTE: [egui::Color32; 5] = [
|
||||
egui::Color32::from_rgb(46, 178, 104),
|
||||
egui::Color32::from_rgb(73, 188, 220),
|
||||
egui::Color32::from_rgb(234, 199, 73),
|
||||
egui::Color32::from_rgb(232, 125, 64),
|
||||
egui::Color32::from_rgb(237, 68, 62),
|
||||
];
|
||||
|
||||
PALETTE[(col + row * 2) % PALETTE.len()]
|
||||
}
|
||||
|
||||
fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 {
|
||||
egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
||||
|
||||
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
|
||||
|
||||
use crate::serial_core::serial::{SerialPortReadWrite, run_serial_loop};
|
||||
use crate::serial_core::serial::{SerialIoStats, SerialPortReadWrite, run_serial_loop};
|
||||
|
||||
/// Connection state visible to the UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -27,6 +27,7 @@ struct Session {
|
||||
cancel_tx: Sender<()>,
|
||||
handle: JoinHandle<()>,
|
||||
sample_rx: Receiver<Vec<i32>>,
|
||||
stats_rx: Receiver<SerialIoStats>,
|
||||
}
|
||||
|
||||
/// Thread-safe connection manager that the UI and renderer can share.
|
||||
@@ -34,6 +35,7 @@ pub struct ConnectionManager {
|
||||
state: Arc<Mutex<ConnectionState>>,
|
||||
session: Arc<Mutex<Option<Session>>>,
|
||||
latest_sample: Arc<Mutex<Option<PressureSample>>>,
|
||||
stats: Arc<Mutex<SerialIoStats>>,
|
||||
}
|
||||
|
||||
impl ConnectionManager {
|
||||
@@ -42,6 +44,7 @@ impl ConnectionManager {
|
||||
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
latest_sample: Arc::new(Mutex::new(None)),
|
||||
stats: Arc::new(Mutex::new(SerialIoStats::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +56,21 @@ impl ConnectionManager {
|
||||
*self.state.lock().unwrap() = new_state;
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> SerialIoStats {
|
||||
let session = self.session.lock().unwrap();
|
||||
if let Some(ref session) = *session {
|
||||
loop {
|
||||
match session.stats_rx.try_recv() {
|
||||
Ok(stats) => *self.stats.lock().unwrap() = stats,
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*self.stats.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Connect to the given serial port and start streaming in a background thread.
|
||||
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) {
|
||||
self.disconnect();
|
||||
@@ -64,6 +82,8 @@ impl ConnectionManager {
|
||||
let latest_sample = Arc::clone(&self.latest_sample);
|
||||
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
|
||||
let (sample_tx, sample_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
||||
let (stats_tx, stats_rx) = crossbeam_channel::bounded::<SerialIoStats>(16);
|
||||
*self.stats.lock().unwrap() = SerialIoStats::default();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let result = run_device_loop(
|
||||
@@ -73,6 +93,7 @@ impl ConnectionManager {
|
||||
&state,
|
||||
&cancel_rx,
|
||||
&sample_tx,
|
||||
&stats_tx,
|
||||
&latest_sample,
|
||||
);
|
||||
if let Err(e) = result {
|
||||
@@ -85,6 +106,7 @@ impl ConnectionManager {
|
||||
cancel_tx,
|
||||
handle,
|
||||
sample_rx,
|
||||
stats_rx,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +124,7 @@ impl ConnectionManager {
|
||||
|
||||
self.set_state(ConnectionState::Disconnected);
|
||||
*self.latest_sample.lock().unwrap() = None;
|
||||
*self.stats.lock().unwrap() = SerialIoStats::default();
|
||||
}
|
||||
|
||||
/// Drain pending samples (non-blocking) and return the last one.
|
||||
@@ -143,6 +166,7 @@ fn run_device_loop(
|
||||
state: &Arc<Mutex<ConnectionState>>,
|
||||
cancel_rx: &Receiver<()>,
|
||||
sample_tx: &Sender<Vec<i32>>,
|
||||
stats_tx: &Sender<SerialIoStats>,
|
||||
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let port = serialport::new(port_name, 921_600)
|
||||
@@ -154,36 +178,17 @@ fn run_device_loop(
|
||||
let mut rw = SerialPortReadWrite::new(port);
|
||||
*state.lock().unwrap() = ConnectionState::Streaming;
|
||||
|
||||
// We need to also forward samples to latest_sample
|
||||
let (inner_tx, inner_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
||||
let latest = Arc::clone(latest_sample);
|
||||
let outer_tx = sample_tx.clone();
|
||||
run_serial_loop(
|
||||
&mut rw,
|
||||
rows as usize,
|
||||
cols as usize,
|
||||
cancel_rx,
|
||||
sample_tx,
|
||||
Some(stats_tx),
|
||||
);
|
||||
|
||||
// Bridge thread: reads from inner channel, forwards to both sample_tx and latest_sample
|
||||
let bridge_cancel = cancel_rx.clone();
|
||||
let bridge_handle = thread::spawn(move || {
|
||||
loop {
|
||||
if bridge_cancel.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
match inner_rx.try_recv() {
|
||||
Ok(vals) => {
|
||||
// Store latest
|
||||
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
|
||||
*latest.lock().unwrap() = Some(PressureSample { matrix, rows, cols });
|
||||
// Forward
|
||||
let _ = outer_tx.try_send(vals);
|
||||
}
|
||||
Err(TryRecvError::Empty) => {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
run_serial_loop(&mut rw, rows as usize, cols as usize, cancel_rx, &inner_tx);
|
||||
|
||||
let _ = bridge_handle.join();
|
||||
if let Ok(mut latest) = latest_sample.lock() {
|
||||
*latest = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod breakout;
|
||||
mod connection;
|
||||
mod matrix;
|
||||
mod model;
|
||||
|
||||
241
src/render.rs
241
src/render.rs
@@ -68,6 +68,23 @@ const HAND_TIP_MATRICES: [HandTipMatrix; 5] = [
|
||||
},
|
||||
];
|
||||
|
||||
const HAND_PALM_CHIPS: [HandPalmChip; 2] = [
|
||||
HandPalmChip {
|
||||
center_px: [538.0, 608.0],
|
||||
size_px: [248.0, 82.0],
|
||||
angle_rad: 0.06,
|
||||
rows: 5,
|
||||
cols: 14,
|
||||
},
|
||||
HandPalmChip {
|
||||
center_px: [606.0, 780.0],
|
||||
size_px: [72.0, 214.0],
|
||||
angle_rad: 0.05,
|
||||
rows: 11,
|
||||
cols: 4,
|
||||
},
|
||||
];
|
||||
|
||||
// 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.
|
||||
@@ -77,6 +94,16 @@ struct HandTipMatrix {
|
||||
angle_rad: f32,
|
||||
}
|
||||
|
||||
// Palm chips follow the hand layout: one horizontal 5x14 matrix and one vertical
|
||||
// 11x4 matrix, rendered as dark inset chip tiles on the palm.
|
||||
struct HandPalmChip {
|
||||
center_px: [f32; 2],
|
||||
size_px: [f32; 2],
|
||||
angle_rad: f32,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
}
|
||||
|
||||
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
||||
fn prepare(
|
||||
&self,
|
||||
@@ -117,6 +144,8 @@ pub struct BackgroundRenderResources {
|
||||
dot_pipeline: wgpu::RenderPipeline,
|
||||
hand_membrane_pipeline: wgpu::RenderPipeline,
|
||||
hand_dot_pipeline: wgpu::RenderPipeline,
|
||||
hand_palm_chip_pipeline: wgpu::RenderPipeline,
|
||||
hand_palm_dot_pipeline: wgpu::RenderPipeline,
|
||||
hand_image_bind_group: wgpu::BindGroup,
|
||||
hand_image_texture: texture::Texture,
|
||||
|
||||
@@ -127,6 +156,10 @@ pub struct BackgroundRenderResources {
|
||||
hand_membrane_instances: Vec<GlyphInstance>,
|
||||
hand_dot_instance_buffer: wgpu::Buffer,
|
||||
hand_dot_instances: Vec<GlyphInstance>,
|
||||
hand_palm_chip_instance_buffer: wgpu::Buffer,
|
||||
hand_palm_chip_instances: Vec<GlyphInstance>,
|
||||
hand_palm_dot_instance_buffer: wgpu::Buffer,
|
||||
hand_palm_dot_instances: Vec<GlyphInstance>,
|
||||
render_options: RenderOptions,
|
||||
}
|
||||
|
||||
@@ -452,6 +485,10 @@ impl BackgroundRenderResources {
|
||||
create_hand_membrane_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||
let hand_dot_pipeline =
|
||||
create_hand_dot_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||
let hand_palm_chip_pipeline =
|
||||
create_hand_palm_chip_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||
let hand_palm_dot_pipeline =
|
||||
create_hand_palm_dot_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||
|
||||
let _model_pipelines =
|
||||
create_model_pipelines(device, target_format, &shader, &model_pipeline_layout);
|
||||
@@ -502,6 +539,29 @@ impl BackgroundRenderResources {
|
||||
contents: bytemuck::cast_slice(&hand_dot_instances),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let hand_palm_chip_instances = build_hand_palm_chip_instances(
|
||||
hand_image_texture.width as f32,
|
||||
hand_image_texture.height as f32,
|
||||
);
|
||||
let hand_palm_chip_instance_buffer =
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Hand Palm Chip Instance Buffer"),
|
||||
contents: bytemuck::cast_slice(&hand_palm_chip_instances),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let hand_palm_dot_instances = build_hand_palm_dot_instances(
|
||||
rows,
|
||||
cols,
|
||||
hand_image_texture.width as f32,
|
||||
hand_image_texture.height as f32,
|
||||
&[[0.0, 0.0]; PRESSURE_CELL_COUNT],
|
||||
);
|
||||
let hand_palm_dot_instance_buffer =
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Hand Palm Chip Dot Instance Buffer"),
|
||||
contents: bytemuck::cast_slice(&hand_palm_dot_instances),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
Self {
|
||||
layout,
|
||||
@@ -517,6 +577,8 @@ impl BackgroundRenderResources {
|
||||
dot_pipeline,
|
||||
hand_membrane_pipeline,
|
||||
hand_dot_pipeline,
|
||||
hand_palm_chip_pipeline,
|
||||
hand_palm_dot_pipeline,
|
||||
hand_image_bind_group,
|
||||
hand_image_texture,
|
||||
glyph_vertex_buffer,
|
||||
@@ -526,6 +588,10 @@ impl BackgroundRenderResources {
|
||||
hand_membrane_instances,
|
||||
hand_dot_instance_buffer,
|
||||
hand_dot_instances,
|
||||
hand_palm_chip_instance_buffer,
|
||||
hand_palm_chip_instances,
|
||||
hand_palm_dot_instance_buffer,
|
||||
hand_palm_dot_instances,
|
||||
render_options,
|
||||
}
|
||||
}
|
||||
@@ -576,6 +642,21 @@ impl BackgroundRenderResources {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.hand_dot_instances),
|
||||
);
|
||||
|
||||
// Palm chips reuse the same live 12x7 pressure frame, but draw it as
|
||||
// embedded micro-pixels inside dark chip tiles.
|
||||
self.hand_palm_dot_instances = build_hand_palm_dot_instances(
|
||||
self.rows,
|
||||
self.cols,
|
||||
self.hand_image_texture.width as f32,
|
||||
self.hand_image_texture.height as f32,
|
||||
pressure,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&self.hand_palm_dot_instance_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.hand_palm_dot_instances),
|
||||
);
|
||||
}
|
||||
|
||||
fn paint(&self, render_pass: &mut wgpu::RenderPass<'_>, active_mode: &ActiveMode) {
|
||||
@@ -623,6 +704,16 @@ impl BackgroundRenderResources {
|
||||
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
|
||||
render_pass.set_vertex_buffer(1, self.hand_dot_instance_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..self.hand_dot_instances.len() as u32);
|
||||
|
||||
render_pass.set_pipeline(&self.hand_palm_chip_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
|
||||
render_pass.set_vertex_buffer(1, self.hand_palm_chip_instance_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..self.hand_palm_chip_instances.len() as u32);
|
||||
|
||||
render_pass.set_pipeline(&self.hand_palm_dot_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
|
||||
render_pass.set_vertex_buffer(1, self.hand_palm_dot_instance_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..self.hand_palm_dot_instances.len() as u32);
|
||||
}
|
||||
|
||||
fn visible_instance_count(&self, rows: u32, cols: u32) -> u32 {
|
||||
@@ -956,6 +1047,72 @@ fn create_hand_dot_pipeline(
|
||||
})
|
||||
}
|
||||
|
||||
fn create_hand_palm_chip_pipeline(
|
||||
device: &wgpu::Device,
|
||||
target_format: &wgpu::TextureFormat,
|
||||
shader: &wgpu::ShaderModule,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Hand Palm Embedded Chip Pipeline"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_hand_palm_chip"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[GlyphVertex::desc(), GlyphInstance::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_hand_palm_chip"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: *target_format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_hand_palm_dot_pipeline(
|
||||
device: &wgpu::Device,
|
||||
target_format: &wgpu::TextureFormat,
|
||||
shader: &wgpu::ShaderModule,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Hand Palm Embedded Chip Dot Pipeline"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_hand_palm_dot"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[GlyphVertex::desc(), GlyphInstance::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_hand_palm_dot"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: *target_format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_hand_membrane_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> {
|
||||
HAND_TIP_MATRICES
|
||||
.iter()
|
||||
@@ -974,6 +1131,28 @@ fn build_hand_membrane_instances(image_width: f32, image_height: f32) -> Vec<Gly
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_hand_palm_chip_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> {
|
||||
HAND_PALM_CHIPS
|
||||
.iter()
|
||||
.map(|chip| {
|
||||
let uv_x = chip.center_px[0] / image_width.max(1.0);
|
||||
let uv_y = chip.center_px[1] / image_height.max(1.0);
|
||||
|
||||
GlyphInstance {
|
||||
// Palm chip shaders read xy as hand.png UV.
|
||||
world_position: [uv_x, uv_y, 0.0, 1.0],
|
||||
// Store chip angle, source-image pixel size, and matrix shape for the shader grid.
|
||||
style: [
|
||||
chip.angle_rad,
|
||||
chip.size_px[0],
|
||||
chip.size_px[1],
|
||||
(chip.rows * 100 + chip.cols) as f32,
|
||||
],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_hand_dot_instances(
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
@@ -1015,6 +1194,68 @@ fn build_hand_dot_instances(
|
||||
instances
|
||||
}
|
||||
|
||||
fn build_hand_palm_dot_instances(
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
image_width: f32,
|
||||
image_height: f32,
|
||||
pressure: &PressureFrame,
|
||||
) -> Vec<GlyphInstance> {
|
||||
let chip_dot_count: usize = HAND_PALM_CHIPS
|
||||
.iter()
|
||||
.map(|chip| (chip.rows * chip.cols) as usize)
|
||||
.sum();
|
||||
let mut instances = Vec::with_capacity(chip_dot_count);
|
||||
|
||||
for chip in HAND_PALM_CHIPS {
|
||||
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.
|
||||
let active_size = [chip.size_px[0] * 0.72, chip.size_px[1] * 0.76];
|
||||
|
||||
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 [normalized, display_value] =
|
||||
pressure.get(index).copied().unwrap_or([0.0, 0.0]);
|
||||
|
||||
let local_x =
|
||||
(col as f32 - chip.cols as f32 / 2.0 + 0.5) / chip.cols as f32 * active_size[0];
|
||||
let local_y =
|
||||
(row as f32 - chip.rows as f32 / 2.0 + 0.5) / chip.rows as f32 * active_size[1];
|
||||
|
||||
let x = chip.center_px[0] + local_x * cos - local_y * sin;
|
||||
let y = chip.center_px[1] + local_x * sin + local_y * cos;
|
||||
|
||||
instances.push(GlyphInstance {
|
||||
world_position: [
|
||||
x / image_width.max(1.0),
|
||||
y / image_height.max(1.0),
|
||||
0.0,
|
||||
1.0,
|
||||
],
|
||||
style: [normalized, display_value, 0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 build_glyph_instances(
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
|
||||
@@ -13,6 +13,15 @@ pub struct TactileACodec {
|
||||
expected_data_len: usize,
|
||||
}
|
||||
|
||||
impl From<u8> for TactileAFrameStatusCode {
|
||||
fn from(value: u8) -> Self {
|
||||
match value {
|
||||
0 => TactileAFrameStatusCode::Success,
|
||||
_ => TactileAFrameStatusCode::Failure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TactileACodec {
|
||||
pub fn new(cols: usize, rows: usize) -> TactileACodec {
|
||||
Self {
|
||||
@@ -37,7 +46,7 @@ impl TactileACodec {
|
||||
Ok(vals)
|
||||
}
|
||||
|
||||
pub fn build_req_frame(cols: usize, rows: usize) -> TactileAFrame {
|
||||
pub fn build_req_frame(cols: usize, rows: usize) -> anyhow::Result<TactileAFrame> {
|
||||
let header = [0x55, 0xAA];
|
||||
let payload_len: usize = 9;
|
||||
let device_addr: u8 = 0x34;
|
||||
@@ -46,7 +55,7 @@ impl TactileACodec {
|
||||
let start_addr: u32 = 7168;
|
||||
let except_data_len: usize = cols * rows * 2;
|
||||
let checksum: u8 = 0;
|
||||
TactileAFrame::Req(TactileAReqFrame {
|
||||
Ok(TactileAFrame::Req(TactileAReqFrame {
|
||||
meta: TactileAFrameMetaData {
|
||||
header,
|
||||
payload_len,
|
||||
@@ -57,7 +66,7 @@ impl TactileACodec {
|
||||
except_data_len,
|
||||
checksum,
|
||||
},
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +111,7 @@ impl Codec<TactileAFrame> for TactileACodec {
|
||||
self.buffer[10],
|
||||
]);
|
||||
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
|
||||
let status = match self.buffer[13] {
|
||||
0 => TactileAFrameStatusCode::Success,
|
||||
_ => TactileAFrameStatusCode::Failure,
|
||||
};
|
||||
let status = TactileAFrameStatusCode::from(self.buffer[13]);
|
||||
|
||||
if except_data_len != self.expected_data_len {
|
||||
log::debug!(
|
||||
|
||||
@@ -9,6 +9,8 @@ pub enum SerialError {
|
||||
AlreadyConnected,
|
||||
StateError,
|
||||
NoRecordedData,
|
||||
ExportError,
|
||||
ImportError,
|
||||
}
|
||||
|
||||
impl fmt::Display for SerialError {
|
||||
@@ -21,6 +23,8 @@ impl fmt::Display for SerialError {
|
||||
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
||||
SerialError::StateError => write!(f, "State Error"),
|
||||
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
||||
SerialError::ExportError => write!(f, "Export Error"),
|
||||
SerialError::ImportError => write!(f, "Import Error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TestFrame {
|
||||
pub header: [u8; 2],
|
||||
pub cmd: u8,
|
||||
pub length: usize,
|
||||
pub payload: Vec<u8>,
|
||||
pub checksum: u8,
|
||||
pub dts_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TactileAFrameMetaData {
|
||||
pub header: [u8; 2],
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use crate::serial_core::codec::Codec;
|
||||
use crate::serial_core::codecs::tactile_a::TactileACodec;
|
||||
use crate::serial_core::frame::TactileAFrame;
|
||||
use crate::serial_core::utils::elapsed_millis;
|
||||
use crossbeam_channel::{Receiver, Sender, TryRecvError};
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use std::io::{Read, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const POLL_INTERVAL_MS: u64 = 10;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SerialIoStats {
|
||||
pub rx_bytes: u64,
|
||||
pub tx_bytes: u64,
|
||||
}
|
||||
|
||||
/// Runs the serial polling loop on the calling (background) thread.
|
||||
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
|
||||
pub fn run_serial_loop(
|
||||
@@ -16,12 +21,20 @@ pub fn run_serial_loop(
|
||||
cols: usize,
|
||||
cancel_rx: &Receiver<()>,
|
||||
sample_tx: &Sender<Vec<i32>>,
|
||||
stats_tx: Option<&Sender<SerialIoStats>>,
|
||||
) {
|
||||
let session_started_at = Instant::now();
|
||||
let mut codec = TactileACodec::new(cols, rows);
|
||||
let req_frame = TactileACodec::build_req_frame(cols, rows);
|
||||
let req_frame = match TactileACodec::build_req_frame(cols, rows) {
|
||||
Ok(frame) => frame,
|
||||
Err(err) => {
|
||||
eprintln!("[serial] request frame build error: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut buffer = [0u8; 1024];
|
||||
let mut poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
||||
let poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
||||
let mut io_stats = SerialIoStats::default();
|
||||
|
||||
loop {
|
||||
// Check cancel
|
||||
@@ -31,7 +44,10 @@ pub fn run_serial_loop(
|
||||
|
||||
// Send poll request
|
||||
if let Ok(req_bytes) = codec.encode(&req_frame) {
|
||||
let _ = port.write_all(&req_bytes);
|
||||
if port.write_all(&req_bytes).is_ok() {
|
||||
io_stats.tx_bytes += req_bytes.len() as u64;
|
||||
publish_stats(stats_tx, io_stats);
|
||||
}
|
||||
}
|
||||
|
||||
// Read response with poll interval
|
||||
@@ -43,6 +59,8 @@ pub fn run_serial_loop(
|
||||
|
||||
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 {
|
||||
if let TactileAFrame::Rep(rep) = frame {
|
||||
@@ -68,6 +86,12 @@ pub fn run_serial_loop(
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_stats(stats_tx: Option<&Sender<SerialIoStats>>, stats: SerialIoStats) {
|
||||
if let Some(tx) = stats_tx {
|
||||
let _ = tx.try_send(stats);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait abstracting read+write for the serial port
|
||||
pub trait ReadWrite: Send {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
use std::time::Instant;
|
||||
|
||||
pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] {
|
||||
(n as u16).to_be_bytes()
|
||||
}
|
||||
|
||||
pub fn usize_to_u16_le_bytes(n: usize) -> [u8; 2] {
|
||||
(n as u16).to_be_bytes()
|
||||
}
|
||||
|
||||
pub fn u16_to_hex_be_bytes(n: u16) -> [u8; 2] {
|
||||
n.to_be_bytes()
|
||||
}
|
||||
|
||||
pub fn u16_to_hex_le_bytes(n: u16) -> [u8; 2] {
|
||||
n.to_le_bytes()
|
||||
}
|
||||
|
||||
pub fn calc_crc8_smbus(c: &[u8]) -> u8 {
|
||||
let crc8_smbus = crc::Crc::<u8>::new(&crc::CRC_8_SMBUS);
|
||||
crc8_smbus.checksum(c)
|
||||
}
|
||||
|
||||
pub fn calc_crc8_itu(c: &[u8]) -> u8 {
|
||||
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
|
||||
crc8_itu_alg.checksum(c)
|
||||
@@ -8,3 +29,26 @@ pub fn calc_crc8_itu(c: &[u8]) -> u8 {
|
||||
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};
|
||||
|
||||
#[test]
|
||||
fn test_crc8_itu() {
|
||||
let req_vec = vec![
|
||||
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
|
||||
];
|
||||
let checksum = calc_crc8_itu(req_vec.as_slice());
|
||||
assert_eq!(checksum, 0x7A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crc8_smbus() {
|
||||
let req_vec = vec![
|
||||
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
|
||||
];
|
||||
let checksum = calc_crc8_smbus(req_vec.as_slice());
|
||||
assert_eq!(checksum, 0x2F);
|
||||
}
|
||||
}
|
||||
|
||||
471
src/ui.rs
471
src/ui.rs
@@ -3,9 +3,10 @@ use eframe::egui;
|
||||
use crate::{
|
||||
connection::{ConnectionManager, ConnectionState},
|
||||
recording::Recorder,
|
||||
serial_core::serial::SerialIoStats,
|
||||
style::{
|
||||
self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO,
|
||||
accent_text, dim_text, group_frame, layout, panel_frame, tag_button,
|
||||
dim_text, group_frame, layout, panel_frame, tag_button,
|
||||
},
|
||||
utils::serial_enum,
|
||||
};
|
||||
@@ -22,6 +23,7 @@ pub struct FloatingPanelState {
|
||||
pub struct ConfigPanelState {
|
||||
pub mode: SerialMode,
|
||||
pub port: String,
|
||||
pub available_ports: Vec<String>,
|
||||
pub baud_rate: u32,
|
||||
pub data_bits: u8,
|
||||
pub stop_bits: u8,
|
||||
@@ -78,17 +80,24 @@ impl FloatingPanelState {
|
||||
|
||||
impl Default for ConfigPanelState {
|
||||
fn default() -> Self {
|
||||
let available_ports = serial_enum().unwrap_or_default();
|
||||
let port = available_ports
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "COM3".to_owned());
|
||||
|
||||
Self {
|
||||
mode: SerialMode::Finger,
|
||||
port: "COM3".to_owned(),
|
||||
baud_rate: 115_200,
|
||||
port,
|
||||
available_ports,
|
||||
baud_rate: 921_600,
|
||||
data_bits: 8,
|
||||
stop_bits: 1,
|
||||
parity: Parity::None,
|
||||
timeout_ms: 1000,
|
||||
module_addr: 1,
|
||||
connected: false,
|
||||
auto_reconnect: true,
|
||||
auto_reconnect: false,
|
||||
manual_tx: "01 03 00 00 00 02".to_owned(),
|
||||
model_path: "model/default.eskin".to_owned(),
|
||||
}
|
||||
@@ -307,8 +316,16 @@ pub fn draw_config_panel(
|
||||
ctx: &egui::Context,
|
||||
panel: &mut FloatingPanelState,
|
||||
config: &mut ConfigPanelState,
|
||||
connection: &ConnectionManager,
|
||||
) -> Option<SerialMode> {
|
||||
let mut changed_mode = None;
|
||||
let conn_state = connection.state();
|
||||
let stats = connection.stats();
|
||||
config.connected = matches!(
|
||||
conn_state,
|
||||
ConnectionState::Connected | ConnectionState::Streaming
|
||||
);
|
||||
|
||||
draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| {
|
||||
ui.set_min_width(560.0);
|
||||
|
||||
@@ -318,11 +335,12 @@ pub fn draw_config_panel(
|
||||
|
||||
// draw_mode_row(ui, config);
|
||||
ui.separator();
|
||||
draw_connection_row(ui, config);
|
||||
draw_connection_row(ui, config, connection, conn_state);
|
||||
ui.add_space(8.0);
|
||||
draw_serial_grid(ui, config);
|
||||
ui.add_space(8.0);
|
||||
draw_mode_body(ui, config);
|
||||
// Legacy serial parameter grid is intentionally kept commented for future hardware:
|
||||
// draw_serial_grid(ui, config);
|
||||
// ui.add_space(8.0);
|
||||
draw_mode_body(ui, config, conn_state, stats);
|
||||
});
|
||||
changed_mode
|
||||
}
|
||||
@@ -341,16 +359,22 @@ fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<Ser
|
||||
}
|
||||
// mode_button(ui, &mut config.mode, SerialMode::Model, "模型");
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.checkbox(&mut config.auto_reconnect, "自动");
|
||||
ui.colored_label(dim_text(), "重连");
|
||||
});
|
||||
// Legacy reconnect controls. Current sensors run fixed 921600 baud without auto-reconnect UI.
|
||||
// ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// ui.checkbox(&mut config.auto_reconnect, "自动");
|
||||
// ui.colored_label(dim_text(), "重连");
|
||||
// });
|
||||
});
|
||||
|
||||
changed_to
|
||||
}
|
||||
|
||||
fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
||||
fn draw_connection_row(
|
||||
ui: &mut egui::Ui,
|
||||
config: &mut ConfigPanelState,
|
||||
connection: &ConnectionManager,
|
||||
conn_state: ConnectionState,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::Image::new(egui::include_image!("../static/cpu.png"))
|
||||
@@ -360,48 +384,75 @@ fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
||||
ui.add_space(METRICS.item_gap);
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.label(style::value_text(format!("端口 {}", config.port)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(style::field_label("端口"));
|
||||
egui::ComboBox::from_id_salt("config_ports")
|
||||
.width(126.0)
|
||||
.selected_text(if config.port.is_empty() {
|
||||
"无可用串口".to_owned()
|
||||
} else {
|
||||
config.port.clone()
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for port in &config.available_ports {
|
||||
ui.selectable_value(&mut config.port, port.clone(), port.as_str());
|
||||
}
|
||||
});
|
||||
|
||||
if ui
|
||||
.add(style::icon_button(
|
||||
egui::RichText::new("⟳").color(ONE_DARK_PRO.text).size(16.0),
|
||||
METRICS.icon_button,
|
||||
))
|
||||
.on_hover_text("刷新串口")
|
||||
.clicked()
|
||||
{
|
||||
refresh_config_ports(config);
|
||||
}
|
||||
});
|
||||
ui.label(style::value_text(format!("波特率 {}", config.baud_rate)));
|
||||
let status = if config.connected {
|
||||
"已连接"
|
||||
} else {
|
||||
"未连接"
|
||||
};
|
||||
let status_color = if config.connected {
|
||||
style::status_color_ok()
|
||||
} else {
|
||||
style::status_color_error()
|
||||
};
|
||||
ui.colored_label(status_color, status);
|
||||
ui.colored_label(
|
||||
connection_status_color(conn_state),
|
||||
connection_status_text(conn_state),
|
||||
);
|
||||
});
|
||||
|
||||
ui.add_space(22.0);
|
||||
|
||||
let button_text = if config.connected { "断开" } else { "连接" };
|
||||
let is_connected = matches!(
|
||||
conn_state,
|
||||
ConnectionState::Connected | ConnectionState::Streaming
|
||||
);
|
||||
let button_text = if is_connected { "断开" } else { "连接" };
|
||||
if ui
|
||||
.add(if config.connected {
|
||||
.add(if is_connected {
|
||||
style::danger_button(button_text)
|
||||
} else {
|
||||
style::primary_button(button_text)
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
config.connected = !config.connected;
|
||||
if is_connected {
|
||||
connection.disconnect();
|
||||
} else if !config.port.is_empty() {
|
||||
connection.connect(&config.port, 12, 7);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(18.0);
|
||||
ui.colored_label(
|
||||
if config.auto_reconnect {
|
||||
style::status_color_ok()
|
||||
} else {
|
||||
ONE_DARK_PRO.text_dim
|
||||
},
|
||||
if config.auto_reconnect {
|
||||
"链路保护 开"
|
||||
} else {
|
||||
"链路保护 关"
|
||||
},
|
||||
);
|
||||
// Legacy reconnect/link-protection indicator:
|
||||
// ui.add_space(18.0);
|
||||
// ui.colored_label(
|
||||
// if config.auto_reconnect {
|
||||
// style::status_color_ok()
|
||||
// } else {
|
||||
// ONE_DARK_PRO.text_dim
|
||||
// },
|
||||
// if config.auto_reconnect {
|
||||
// "链路保护 开"
|
||||
// } else {
|
||||
// "链路保护 关"
|
||||
// },
|
||||
// );
|
||||
});
|
||||
}
|
||||
|
||||
@@ -453,41 +504,41 @@ fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
||||
fn draw_mode_body(
|
||||
ui: &mut egui::Ui,
|
||||
config: &mut ConfigPanelState,
|
||||
conn_state: ConnectionState,
|
||||
stats: SerialIoStats,
|
||||
) {
|
||||
group_frame().show(ui, |ui| match config.mode {
|
||||
SerialMode::Finger => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(style::field_label("模块地址"));
|
||||
ui.add_sized(
|
||||
egui::vec2(80.0, METRICS.field_height),
|
||||
egui::DragValue::new(&mut config.module_addr).range(1..=247),
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
if ui.add(tag_button("读取信息")).clicked() {}
|
||||
if ui.add(tag_button("探测")).clicked() {}
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(dim_text(), "状态");
|
||||
ui.label("就绪");
|
||||
ui.colored_label(dim_text(), "接收");
|
||||
ui.label("0 字节");
|
||||
ui.colored_label(dim_text(), "发送");
|
||||
ui.label("0 字节");
|
||||
});
|
||||
// Legacy module-address controls for older devices:
|
||||
// ui.horizontal(|ui| {
|
||||
// ui.label(style::field_label("模块地址"));
|
||||
// ui.add_sized(
|
||||
// egui::vec2(80.0, METRICS.field_height),
|
||||
// egui::DragValue::new(&mut config.module_addr).range(1..=247),
|
||||
// );
|
||||
// ui.add_space(16.0);
|
||||
// if ui.add(tag_button("读取信息")).clicked() {}
|
||||
// if ui.add(tag_button("探测")).clicked() {}
|
||||
// });
|
||||
draw_status_bytes_row(ui, conn_state, stats);
|
||||
}
|
||||
SerialMode::Hand => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(style::field_label("发送"));
|
||||
ui.add_sized(
|
||||
egui::vec2(300.0, METRICS.field_height),
|
||||
egui::TextEdit::singleline(&mut config.manual_tx),
|
||||
);
|
||||
if ui.add(tag_button("发送")).clicked() {}
|
||||
if ui.add(tag_button("清空")).clicked() {
|
||||
config.manual_tx.clear();
|
||||
}
|
||||
});
|
||||
// Legacy manual-TX controls for older hand-module debugging:
|
||||
// ui.horizontal(|ui| {
|
||||
// ui.label(style::field_label("发送"));
|
||||
// ui.add_sized(
|
||||
// egui::vec2(300.0, METRICS.field_height),
|
||||
// egui::TextEdit::singleline(&mut config.manual_tx),
|
||||
// );
|
||||
// if ui.add(tag_button("发送")).clicked() {}
|
||||
// if ui.add(tag_button("清空")).clicked() {
|
||||
// config.manual_tx.clear();
|
||||
// }
|
||||
// });
|
||||
draw_status_bytes_row(ui, conn_state, stats);
|
||||
} // SerialMode::Model => {
|
||||
// ui.horizontal(|ui| {
|
||||
// ui.label(style::field_label("模型"));
|
||||
@@ -502,6 +553,45 @@ fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_status_bytes_row(ui: &mut egui::Ui, conn_state: ConnectionState, stats: SerialIoStats) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(dim_text(), "状态");
|
||||
ui.label(connection_status_text(conn_state));
|
||||
ui.colored_label(dim_text(), "接收");
|
||||
ui.label(format!("{} 字节", stats.rx_bytes));
|
||||
ui.colored_label(dim_text(), "发送");
|
||||
ui.label(format!("{} 字节", stats.tx_bytes));
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_config_ports(config: &mut ConfigPanelState) {
|
||||
if let Ok(ports) = serial_enum() {
|
||||
if !ports.contains(&config.port) {
|
||||
config.port = ports.first().cloned().unwrap_or_default();
|
||||
}
|
||||
config.available_ports = ports;
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_status_text(state: ConnectionState) -> &'static str {
|
||||
match state {
|
||||
ConnectionState::Disconnected => "未连接",
|
||||
ConnectionState::Connecting => "连接中...",
|
||||
ConnectionState::Connected => "已连接",
|
||||
ConnectionState::Streaming => "采集中",
|
||||
ConnectionState::Error => "连接错误",
|
||||
}
|
||||
}
|
||||
|
||||
fn connection_status_color(state: ConnectionState) -> egui::Color32 {
|
||||
match state {
|
||||
ConnectionState::Disconnected => style::status_color_error(),
|
||||
ConnectionState::Connecting => style::status_color_warn(),
|
||||
ConnectionState::Connected | ConnectionState::Streaming => style::status_color_ok(),
|
||||
ConnectionState::Error => style::status_color_error(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon_button<'a>(
|
||||
ui: &mut egui::Ui,
|
||||
icon: IconButtonIcon<'a>,
|
||||
@@ -578,21 +668,236 @@ fn parity_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn draw_stats_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) {
|
||||
pub fn draw_stats_panel(
|
||||
ctx: &egui::Context,
|
||||
panel: &mut FloatingPanelState,
|
||||
force_history: &[f32],
|
||||
) {
|
||||
draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(accent_text(), "0.030");
|
||||
ui.label(style::subtle_text("81m:51s"));
|
||||
});
|
||||
ui.separator();
|
||||
group_frame().show(ui, |ui| {
|
||||
ui.label(style::field_label("帧率 / GPU 信息"));
|
||||
ui.label(style::value_text("边界 589.0us"));
|
||||
ui.label(style::value_text("簇 12.8ms"));
|
||||
});
|
||||
ui.set_min_width(320.0);
|
||||
draw_resultant_force_chart(ui, force_history);
|
||||
});
|
||||
}
|
||||
|
||||
const FORCE_CHART_MAX_N: f32 = 25.6;
|
||||
|
||||
fn draw_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
|
||||
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);
|
||||
let min = active_values.fold(None, |best: Option<f32>, value| {
|
||||
Some(best.map_or(value, |current| current.min(value)))
|
||||
});
|
||||
|
||||
group_frame().show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(ACCENT_BLUE, "RF");
|
||||
ui.label(style::panel_title("合力"));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.colored_label(ACCENT_GREEN, format!("{latest:.1} N"));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
force_metric(ui, "峰值", max);
|
||||
ui.separator();
|
||||
force_metric(ui, "低值", min.unwrap_or(0.0));
|
||||
ui.separator();
|
||||
ui.colored_label(dim_text(), format!("{} 点", values.len()));
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
paint_resultant_force_chart(ui, values);
|
||||
});
|
||||
}
|
||||
|
||||
fn force_metric(ui: &mut egui::Ui, label: &'static str, value: f32) {
|
||||
ui.colored_label(dim_text(), label);
|
||||
ui.label(style::value_text(format!("{value:.1} N")));
|
||||
}
|
||||
|
||||
fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
|
||||
const CHART_HEIGHT: f32 = 138.0;
|
||||
const CHART_POINTS: usize = 42;
|
||||
|
||||
let width = ui.available_width().max(280.0);
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, CHART_HEIGHT), egui::Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
let radius = egui::CornerRadius::same(5);
|
||||
|
||||
painter.rect_filled(rect, radius, egui::Color32::from_rgb(10, 18, 22));
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
radius,
|
||||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.accent, 95)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
|
||||
let chart_rect = egui::Rect::from_min_max(
|
||||
rect.left_top() + egui::vec2(34.0, 12.0),
|
||||
rect.right_bottom() - egui::vec2(10.0, 24.0),
|
||||
);
|
||||
|
||||
paint_force_grid(&painter, rect, chart_rect);
|
||||
|
||||
if values.is_empty() {
|
||||
painter.text(
|
||||
chart_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"等待合力数据",
|
||||
egui::FontId::proportional(12.0),
|
||||
ONE_DARK_PRO.text_subtle,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.ctx().request_repaint();
|
||||
|
||||
let time = ui.ctx().input(|input| input.time) as f32;
|
||||
let enter = ui
|
||||
.ctx()
|
||||
.animate_bool(egui::Id::new("resultant_force_chart_enter"), true);
|
||||
let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65;
|
||||
let plot_rect = chart_rect.translate(egui::vec2(slide_offset, 0.0));
|
||||
|
||||
let mut points = Vec::with_capacity(values.len());
|
||||
let start_slot = CHART_POINTS.saturating_sub(values.len());
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
let denom = (CHART_POINTS - 1).max(1) as f32;
|
||||
let slot = start_slot + index;
|
||||
let x = plot_rect.left() + (slot as f32 / denom) * plot_rect.width();
|
||||
let normalized = (*value / FORCE_CHART_MAX_N).clamp(0.0, 1.0);
|
||||
let y = plot_rect.bottom() - normalized * plot_rect.height();
|
||||
points.push(egui::pos2(x, y));
|
||||
}
|
||||
|
||||
let plot_painter = painter.with_clip_rect(chart_rect.expand2(egui::vec2(1.0, 1.0)));
|
||||
paint_force_sweep(&plot_painter, chart_rect, time);
|
||||
paint_force_area(&plot_painter, &points, plot_rect);
|
||||
|
||||
if points.len() >= 2 {
|
||||
plot_painter.add(egui::Shape::line(
|
||||
points.clone(),
|
||||
egui::Stroke::new(4.0, color_alpha(ONE_DARK_PRO.accent, 45)),
|
||||
));
|
||||
plot_painter.add(egui::Shape::line(
|
||||
points.clone(),
|
||||
egui::Stroke::new(1.7, ONE_DARK_PRO.accent),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(last) = points.last().copied() {
|
||||
let pulse = (time * 1.8).fract();
|
||||
plot_painter.circle_stroke(
|
||||
last,
|
||||
5.0 + pulse * 12.0,
|
||||
egui::Stroke::new(
|
||||
1.0,
|
||||
color_alpha(ACCENT_GREEN, ((1.0 - pulse) * 120.0) as u8),
|
||||
),
|
||||
);
|
||||
plot_painter.circle_filled(last, 3.6, ACCENT_GREEN);
|
||||
plot_painter.circle_filled(last, 1.6, egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_force_grid(painter: &egui::Painter, rect: egui::Rect, chart_rect: egui::Rect) {
|
||||
for tick in [25.0, 20.0, 15.0, 10.0, 5.0, 0.0] {
|
||||
let normalized = (tick / FORCE_CHART_MAX_N).clamp(0.0, 1.0);
|
||||
let y = chart_rect.bottom() - normalized * chart_rect.height();
|
||||
painter.line_segment(
|
||||
[
|
||||
egui::pos2(chart_rect.left(), y),
|
||||
egui::pos2(chart_rect.right(), y),
|
||||
],
|
||||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border, 78)),
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 8.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
format!("{tick:.0}"),
|
||||
egui::FontId::monospace(9.0),
|
||||
ONE_DARK_PRO.text_dim,
|
||||
);
|
||||
}
|
||||
|
||||
for index in 0..=5 {
|
||||
let x = chart_rect.left() + index as f32 / 5.0 * chart_rect.width();
|
||||
painter.line_segment(
|
||||
[
|
||||
egui::pos2(x, chart_rect.top()),
|
||||
egui::pos2(x, chart_rect.bottom()),
|
||||
],
|
||||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border_soft, 42)),
|
||||
);
|
||||
}
|
||||
|
||||
painter.text(
|
||||
egui::pos2(chart_rect.left(), rect.bottom() - 11.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
"-4.2s",
|
||||
egui::FontId::monospace(9.0),
|
||||
ONE_DARK_PRO.text_subtle,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(chart_rect.right(), rect.bottom() - 11.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
"now",
|
||||
egui::FontId::monospace(9.0),
|
||||
ONE_DARK_PRO.text_subtle,
|
||||
);
|
||||
}
|
||||
|
||||
fn paint_force_sweep(painter: &egui::Painter, chart_rect: egui::Rect, time: f32) {
|
||||
let sweep = (time * 0.55).fract();
|
||||
let x = chart_rect.left() + sweep * chart_rect.width();
|
||||
let sweep_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(x - 8.0, chart_rect.top()),
|
||||
egui::pos2(x + 8.0, chart_rect.bottom()),
|
||||
);
|
||||
|
||||
painter.rect_filled(
|
||||
sweep_rect,
|
||||
egui::CornerRadius::ZERO,
|
||||
color_alpha(ONE_DARK_PRO.accent, 20),
|
||||
);
|
||||
painter.line_segment(
|
||||
[
|
||||
egui::pos2(x, chart_rect.top()),
|
||||
egui::pos2(x, chart_rect.bottom()),
|
||||
],
|
||||
egui::Stroke::new(1.0, color_alpha(ACCENT_GREEN, 95)),
|
||||
);
|
||||
}
|
||||
|
||||
fn paint_force_area(painter: &egui::Painter, points: &[egui::Pos2], plot_rect: egui::Rect) {
|
||||
if points.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
for pair in points.windows(2) {
|
||||
let left = pair[0];
|
||||
let right = pair[1];
|
||||
let area = vec![
|
||||
egui::pos2(left.x, plot_rect.bottom()),
|
||||
left,
|
||||
right,
|
||||
egui::pos2(right.x, plot_rect.bottom()),
|
||||
];
|
||||
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
area,
|
||||
color_alpha(ONE_DARK_PRO.accent, 32),
|
||||
egui::Stroke::NONE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 {
|
||||
egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha)
|
||||
}
|
||||
|
||||
fn draw_floating_panel(
|
||||
ctx: &egui::Context,
|
||||
panel: &mut FloatingPanelState,
|
||||
|
||||
Reference in New Issue
Block a user