Compare commits

..

5 Commits

Author SHA1 Message Date
lennlouisgeek
5f1c217853 feat: 添加 README,更新 .gitignore,移除 JE-Skin/eskin-finger-sdk
- 添加 README.md 项目文档
- 更新 .gitignore 排除 JE-Skin/、eskin-finger-sdk/ 及构建产物
- 从 git 跟踪中移除 JE-Skin 和 eskin-finger-sdk

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 03:15:16 +08:00
lennlouisgeek
d2c9fad556 Wire live serial data into matrix renderer 2026-05-20 01:23:02 +08:00
lennlouisgeek
a7b617419d feat: current progress - connect panel layout, manual checkbox, serial enum 2026-05-19 23:44:09 +08:00
lennlouisgeek
83faa0be1e feat: 添加 app/theme/ui/matrix/render 模块,重构 shader 2026-05-19 20:05:13 +08:00
lennlouisgeek
aff9c2a75c feat: 添加 app/theme/ui/matrix/render 模块,重构 shader 2026-05-18 02:17:46 +08:00
23 changed files with 3022 additions and 586 deletions

9
.gitignore vendored
View File

@@ -1 +1,10 @@
/target
JE-Skin/
eskin-finger-sdk/
*.err
*.out
*.exe
*.pdb
*.d
*.rlib
*.rmeta

243
Cargo.lock generated
View File

@@ -36,7 +36,7 @@ dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"atspi-common",
"phf",
"phf 0.13.1",
"serde",
"zvariant",
]
@@ -870,6 +870,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -879,6 +894,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1079,6 +1103,21 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c609fc87f6c70ffd3afd679cbb294985096d2fc0be33e762ad5614bde4925bc"
dependencies = [
"ahash",
"egui",
"enum-map",
"image",
"log",
"mime_guess2",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.34.2"
@@ -1117,6 +1156,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -1237,10 +1296,15 @@ version = "0.5.0"
dependencies = [
"anyhow",
"bytemuck",
"crc",
"crossbeam-channel",
"eframe",
"egui_extras",
"env_logger",
"glam",
"image",
"log",
"serialport",
]
[[package]]
@@ -1854,6 +1918,16 @@ dependencies = [
"syn",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -2064,6 +2138,26 @@ dependencies = [
"redox_syscall 0.7.5",
]
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@@ -2118,6 +2212,15 @@ dependencies = [
"imgref",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -2152,6 +2255,24 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess2"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
dependencies = [
"mime",
"phf 0.11.3",
"phf_shared 0.11.3",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2234,6 +2355,17 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -2756,17 +2888,37 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_shared",
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
@@ -2774,7 +2926,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
"unicase",
]
[[package]]
@@ -2783,13 +2949,23 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
"phf_shared",
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
"unicase",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -3028,6 +3204,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
@@ -3035,7 +3220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@@ -3045,9 +3230,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -3090,7 +3281,7 @@ dependencies = [
"num-traits",
"paste",
"profiling",
"rand",
"rand 0.9.4",
"rand_chacha",
"simd_helpers",
"thiserror 2.0.18",
@@ -3382,6 +3573,25 @@ dependencies = [
"syn",
]
[[package]]
name = "serialport"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"core-foundation 0.10.1",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix",
"scopeguard",
"unescaper",
"windows-sys 0.52.0",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -3786,6 +3996,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unescaper"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"

View File

@@ -10,3 +10,8 @@ bytemuck = { version = "1", features = ["derive"] }
glam = "0.32.1"
image = { version = "0.25.10", features = ["png", "jpeg"] }
anyhow = "1.0.102"
serialport = "4.9.0"
egui_extras = { version = "0.34.2", features = ["image"] }
crossbeam-channel = "0.5.15"
crc = "3.4.0"
log = "0.4.29"

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# Eskin Model Player
实时压力矩阵可视化桌面应用,用于连接 E-Skin 传感器设备并通过串口接收压力数据,以热力图方式实时渲染。
## 功能
- 串口连接 E-Skin 传感器921600 baud
- 实时压力矩阵热力图渲染wgpu
- 自定义无边框窗口macOS 风格标题栏
- 浮动面板:连接管理、场景视图、配置、数据统计
## 依赖
- Rust 2024 edition
- [eframe](https://crates.io/crates/eframe) 0.34egui + wgpu
- [serialport](https://crates.io/crates/serialport) 4.9
## 构建与运行
```bash
cargo run --release
```
## 项目结构
```
src/
├── main.rs # 入口,创建 eframe 窗口
├── app.rs # 应用主循环与面板调度
├── connection.rs # 串口连接管理(后台线程)
├── serial_core/ # 串口协议编解码
│ ├── serial.rs # 串口读写循环
│ ├── codec.rs # 帧编解码器
│ ├── frame.rs # 帧结构定义
│ └── ...
├── render.rs # wgpu 渲染管线(背景 + 数字叠加)
├── matrix.rs # 矩阵布局与坐标变换
├── ui.rs # egui 浮动面板 UI
├── theme.rs # 深色工程主题
├── shader.wgsl # WGSL 着色器
└── utils.rs # 工具函数
```

277
src/app.rs Normal file
View File

@@ -0,0 +1,277 @@
use eframe::{egui, egui_wgpu};
use std::sync::Arc;
use crate::connection::ConnectionManager;
use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme};
use crate::{
matrix::{MATRIX_COLS, MATRIX_ROWS},
render::{
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback,
},
ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel,
draw_connect_panel, draw_scene_panel, draw_stats_panel,
},
};
const DATA_LOG_EVERY_FRAMES: u64 = 30;
pub struct EskinDesktopApp {
connect_panel: FloatingPanelState,
connect_state: ConnectPanelState,
connection: Arc<ConnectionManager>,
pressure_matrix: PressureFrame,
data_log_frame: u64,
scene_panel: FloatingPanelState,
config_panel: FloatingPanelState,
config_state: ConfigPanelState,
stats_panel: FloatingPanelState,
}
impl EskinDesktopApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
egui_extras::install_image_loaders(&cc.egui_ctx);
apply_fonts(&cc.egui_ctx);
apply_theme(&cc.egui_ctx, &ENGINEERING_DARK);
let wgpu_state = cc
.wgpu_render_state
.as_ref()
.expect("need open eframe wgpu renderer feature");
let mut renderer = wgpu_state.renderer.write();
renderer
.callback_resources
.insert(BackgroundRenderResources::new(
&wgpu_state.device,
&wgpu_state.target_format,
MATRIX_ROWS,
MATRIX_COLS,
));
Self {
connect_panel: FloatingPanelState::new([0.0, 0.0], [0.0, 0.0]),
connect_state: ConnectPanelState::default(),
connection: Arc::new(ConnectionManager::new()),
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
data_log_frame: 0,
scene_panel: FloatingPanelState::new([16.0, 48.0], [16.0, 48.0]),
config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]),
config_state: ConfigPanelState::default(),
stats_panel: FloatingPanelState::new([16.0, 520.0], [240.0, 48.0]),
}
}
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
self.update_pressure_matrix();
let rect = ui.max_rect();
let width = rect.width().max(1.0);
let height = rect.height().max(1.0);
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
WgpuBackgroundCallback {
width,
height,
pressure: self.pressure_matrix,
},
));
}
fn update_pressure_matrix(&mut self) {
if let Some(sample) = self.connection.take_latest_sample() {
normalize_pressure_sample(
&sample.matrix,
sample.rows,
sample.cols,
&mut self.pressure_matrix,
);
self.data_log_frame += 1;
if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 {
log_pressure_sample(&sample.matrix, sample.rows, sample.cols);
}
}
}
fn draw_title_bar(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let title_bar_height = 36.0;
let title_bar_rect = ui
.allocate_space(egui::vec2(ui.available_width(), title_bar_height))
.1;
// Paint background
ui.painter().rect_filled(
title_bar_rect,
egui::CornerRadius::ZERO,
ENGINEERING_DARK.panel_deep,
);
ui.painter().line_segment(
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
egui::Stroke::new(1.0, ENGINEERING_DARK.border),
);
// Drag-to-move: double-click to maximize, drag to move
let title_bar_response = ui.interact(
title_bar_rect,
egui::Id::new("title_bar"),
egui::Sense::click_and_drag(),
);
if title_bar_response.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if title_bar_response.double_clicked() {
let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized));
}
// macOS traffic light buttons (close / minimize / maximize)
let btn_size = 14.0;
let btn_spacing = 8.0;
let btns_start = title_bar_rect.left_center() + egui::vec2(12.0, 0.0);
let btn_close_center = btns_start;
let btn_min_center = btns_start + egui::vec2(btn_size + btn_spacing, 0.0);
let btn_max_center = btns_start + egui::vec2((btn_size + btn_spacing) * 2.0, 0.0);
// Close (red)
let close_rect =
egui::Rect::from_center_size(btn_close_center, egui::vec2(btn_size, btn_size));
let close_resp = ui.interact(close_rect, egui::Id::new("btn_close"), egui::Sense::click());
let close_color = egui::Color32::from_rgb(255, 95, 86);
ui.painter()
.circle_filled(btn_close_center, btn_size / 2.0, close_color);
// Draw × when hovered
if close_resp.hovered() {
ui.painter().line_segment(
[
btn_close_center + egui::vec2(-3.0, -3.0),
btn_close_center + egui::vec2(3.0, 3.0),
],
egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)),
);
ui.painter().line_segment(
[
btn_close_center + egui::vec2(3.0, -3.0),
btn_close_center + egui::vec2(-3.0, 3.0),
],
egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)),
);
}
if close_resp.clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
// Minimize (yellow)
let min_rect = egui::Rect::from_center_size(btn_min_center, egui::vec2(btn_size, btn_size));
let min_resp = ui.interact(min_rect, egui::Id::new("btn_min"), egui::Sense::click());
let min_color = egui::Color32::from_rgb(255, 189, 46);
ui.painter()
.circle_filled(btn_min_center, btn_size / 2.0, min_color);
if min_resp.hovered() {
ui.painter().line_segment(
[
btn_min_center + egui::vec2(-3.0, 0.0),
btn_min_center + egui::vec2(3.0, 0.0),
],
egui::Stroke::new(1.5, egui::Color32::from_rgb(120, 80, 0)),
);
}
if min_resp.clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
}
// Maximize (green)
let max_rect = egui::Rect::from_center_size(btn_max_center, egui::vec2(btn_size, btn_size));
let max_resp = ui.interact(max_rect, egui::Id::new("btn_max"), egui::Sense::click());
let max_color = egui::Color32::from_rgb(39, 201, 63);
ui.painter()
.circle_filled(btn_max_center, btn_size / 2.0, max_color);
if max_resp.hovered() {
let s = 3.0;
ui.painter().rect_stroke(
egui::Rect::from_center_size(btn_max_center, egui::vec2(s * 2.0, s * 2.0)),
egui::CornerRadius::same(1),
egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 80, 10)),
egui::StrokeKind::Outside,
);
}
if max_resp.clicked() {
let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized));
}
}
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
draw_connect_panel(
ctx,
&mut self.connect_panel,
&mut self.connect_state,
&self.connection,
);
draw_scene_panel(ctx, &mut self.scene_panel);
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state);
draw_stats_panel(ctx, &mut self.stats_panel);
}
}
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 normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;
normalized.fill([0.0, 0.0]);
let src_cols = cols.max(1);
let copy_rows = MATRIX_ROWS.min(rows);
let copy_cols = MATRIX_COLS.min(cols);
for row in 0..copy_rows {
for col in 0..copy_cols {
let src_index = (row * src_cols + col) as usize;
let dst_index = (row * MATRIX_COLS + col) as usize;
if let Some(value) = raw.get(src_index) {
let raw_value = *value as f32;
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0);
let display_value = if raw_value <= RANGE_MIN + 4.0 {
0.0
} else {
raw_value.round().min(9999.0)
};
normalized[dst_index] = [mapped, display_value];
}
}
}
}
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_title_bar(ui, frame);
self.draw_floating_panels(&ctx);
// Keep repainting while the wgpu background is a realtime viewport.
ctx.request_repaint();
}
}

189
src/connection.rs Normal file
View File

@@ -0,0 +1,189 @@
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
use crate::serial_core::serial::{SerialPortReadWrite, run_serial_loop};
/// Connection state visible to the UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
Streaming,
Error,
}
/// A pressure sample forwarded to the render layer.
pub struct PressureSample {
pub matrix: Vec<u32>,
pub rows: u32,
pub cols: u32,
}
struct Session {
cancel_tx: Sender<()>,
handle: JoinHandle<()>,
sample_rx: Receiver<Vec<i32>>,
}
/// Thread-safe connection manager that the UI and renderer can share.
pub struct ConnectionManager {
state: Arc<Mutex<ConnectionState>>,
session: Arc<Mutex<Option<Session>>>,
latest_sample: Arc<Mutex<Option<PressureSample>>>,
}
impl ConnectionManager {
pub fn new() -> Self {
Self {
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
session: Arc::new(Mutex::new(None)),
latest_sample: Arc::new(Mutex::new(None)),
}
}
pub fn state(&self) -> ConnectionState {
*self.state.lock().unwrap()
}
pub fn set_state(&self, new_state: ConnectionState) {
*self.state.lock().unwrap() = new_state;
}
/// 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();
self.set_state(ConnectionState::Connecting);
let port = port_name.to_owned();
let state = Arc::clone(&self.state);
let session_guard = Arc::clone(&self.session);
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 handle = thread::spawn(move || {
let result = run_device_loop(
&port,
rows,
cols,
&state,
&cancel_rx,
&sample_tx,
&latest_sample,
);
if let Err(e) = result {
eprintln!("[connection] device loop error: {e}");
*state.lock().unwrap() = ConnectionState::Error;
}
});
*session_guard.lock().unwrap() = Some(Session {
cancel_tx,
handle,
sample_rx,
});
}
/// Disconnect from the device, stopping the background thread.
pub fn disconnect(&self) {
let session = {
let mut guard = self.session.lock().unwrap();
guard.take()
};
if let Some(session) = session {
let _ = session.cancel_tx.send(());
let _ = session.handle.join();
}
self.set_state(ConnectionState::Disconnected);
*self.latest_sample.lock().unwrap() = None;
}
/// Drain pending samples (non-blocking) and return the last one.
pub fn take_latest_sample(&self) -> Option<PressureSample> {
let session = self.session.lock().unwrap();
if let Some(ref session) = *session {
let mut last = None;
loop {
match session.sample_rx.try_recv() {
Ok(vals) => {
let rows = 12u32;
let cols = 7u32;
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
last = Some(PressureSample { matrix, rows, cols });
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
if last.is_some() {
return last;
}
}
None
}
}
impl Default for ConnectionManager {
fn default() -> Self {
Self::new()
}
}
/// The blocking device loop that runs on a background thread.
fn run_device_loop(
port_name: &str,
rows: u32,
cols: u32,
state: &Arc<Mutex<ConnectionState>>,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let port = serialport::new(port_name, 921_600)
.timeout(Duration::from_millis(100))
.open()?;
*state.lock().unwrap() = ConnectionState::Connected;
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();
// 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();
Ok(())
}

View File

@@ -1,553 +1,29 @@
pub mod texture;
mod app;
mod connection;
mod matrix;
mod render;
mod serial_core;
mod theme;
mod ui;
mod utils;
use app::EskinDesktopApp;
use eframe::egui;
use bytemuck;
use core::f32::consts;
use eframe::{
egui,
egui_wgpu::{self, wgpu},
wgpu::util::DeviceExt,
};
const NUM_INSTANCES_PER_ROW: u32 = 10;
const INSTANCE_DISPLACEMENT: glam::Vec3 = glam::Vec3::new(
NUM_INSTANCES_PER_ROW as f32 * 0.5,
0.0,
NUM_INSTANCES_PER_ROW as f32 * 0.5,
);
fn main() -> eframe::Result<()> {
env_logger::init();
let options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu,
viewport: egui::ViewportBuilder::default()
.with_inner_size([1920.0, 1080.0])
.with_min_inner_size([1280.0, 720.0])
.with_decorations(false),
..Default::default()
};
eframe::run_native(
"Eskin Model Player",
"Eskin 模型播放器",
options,
Box::new(|cc| Ok(Box::new(EskinDesktopApp::new(cc)))),
)
}
struct EskinDesktopApp {
show_scene_panel: bool,
show_config_panel: bool,
show_stats_panel: bool,
}
impl EskinDesktopApp {
fn new(cc: &eframe::CreationContext<'_>) -> Self {
let wgpu_state = cc
.wgpu_render_state
.as_ref()
.expect("need open eframe wgpu renderer feature");
let mut renderer = wgpu_state.renderer.write();
renderer
.callback_resources
.insert(BackgroundRenderResources::new(
&wgpu_state.device,
&wgpu_state.target_format,
&wgpu_state.queue,
12,
7,
));
Self {
show_scene_panel: true,
show_config_panel: true,
show_stats_panel: true,
}
}
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
let rect = ui.max_rect();
let width = rect.width();
let height = rect.height();
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
WgpuBackgroundCallback {
aspect: width / height,
},
));
}
fn draw_toolbar(&mut self, ui: &mut egui::Ui) {
egui::Panel::top("main_menu").show_inside(ui, |ui| {
ui.horizontal(|ui| {
ui.checkbox(&mut self.show_scene_panel, "Scene");
ui.checkbox(&mut self.show_config_panel, "Config");
ui.checkbox(&mut self.show_stats_panel, "Stats");
});
});
}
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
egui::Window::new("Scene")
.open(&mut self.show_scene_panel)
.default_pos([16.0, 48.0])
.show(ctx, |ui| {
ui.label("Models / materials / lights");
});
egui::Window::new("Config")
.open(&mut self.show_config_panel)
.default_pos([840.0, 48.0])
.show(ctx, |ui| {
ui.label("Render and viewport settings");
});
egui::Window::new("Stats")
.open(&mut self.show_stats_panel)
.default_pos([16.0, 520.0])
.show(ctx, |ui| {
ui.label("FPS / GPU info");
});
}
}
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_toolbar(ui);
self.draw_floating_panels(&ctx);
// Keep repainting while the wgpu background is a realtime viewport.
ctx.request_repaint();
}
}
struct WgpuBackgroundCallback {
aspect: f32,
}
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
fn prepare(
&self,
_device: &wgpu::Device,
queue: &wgpu::Queue,
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
_egui_encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
resources.prepare(queue, self.aspect);
Vec::new()
}
fn paint(
&self,
_info: egui::PaintCallbackInfo,
render_pass: &mut wgpu::RenderPass<'static>,
resources: &egui_wgpu::CallbackResources,
) {
let resources: &BackgroundRenderResources = resources.get().unwrap();
resources.paint(render_pass);
}
}
struct BackgroundRenderResources {
camera: Camera,
camera_uniform: CameraUniform,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
#[allow(dead_code)]
diffuse_texture: texture::Texture,
diffuse_bind_group: wgpu::BindGroup,
render_pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
index_buffer: wgpu::Buffer,
num_indices: u32,
instance: Vec<Instance>,
instance_buffer: wgpu::Buffer,
cols: Option<u8>,
rows: Option<u8>,
}
impl BackgroundRenderResources {
fn new(
device: &wgpu::Device,
target_format: &wgpu::TextureFormat,
queue: &wgpu::Queue,
rows: u8,
cols: u8,
) -> Self {
let diffuse_bytes = include_bytes!("happy-tree.png");
let diffuse_texture =
texture::Texture::from_bytes(device, queue, diffuse_bytes, "happy-tree.png").unwrap();
let texture_group_bind_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("texture_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let diffuse_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("texture_bind_group"),
layout: &texture_group_bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&diffuse_texture.view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&diffuse_texture.sampler),
},
],
});
let camera = Camera {
eye: (0.0, 5.0, 10.0).into(),
target: (0.0, 0.0, 0.0).into(),
up: glam::Vec3::Y,
aspect: 1.0,
fovy: 45.0,
znear: 0.1,
zfar: 100.0,
};
let mut camera_uniform = CameraUniform::new();
camera_uniform.update_view_proj(&camera);
let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Camera Buffer"),
contents: bytemuck::cast_slice(&[camera_uniform]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let instances = (0..NUM_INSTANCES_PER_ROW)
.flat_map(|z| {
(0..NUM_INSTANCES_PER_ROW).map(move |x| {
let position = glam::Vec3 {
x: x as f32,
y: 0.0,
z: z as f32,
} - INSTANCE_DISPLACEMENT;
let rotation = if position.length().abs() <= f32::EPSILON {
// this is needed so an object at (0, 0, 0) won't get scaled to zero
// as Quaternions can effect scale if they're not create correctly
glam::Quat::from_axis_angle(glam::Vec3::Z, 0.0)
} else {
glam::Quat::from_axis_angle(position.normalize(), consts::FRAC_PI_2)
};
Instance { position, rotation }
})
})
.collect::<Vec<_>>();
let instance_data = instances.iter().map(Instance::to_raw).collect::<Vec<_>>();
let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Instance Buffer"),
contents: bytemuck::cast_slice(&instance_data),
usage: wgpu::BufferUsages::VERTEX,
});
let camera_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("camera_bind_group_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("camera_bind_group"),
layout: &camera_bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: camera_buffer.as_entire_binding(),
}],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
});
let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Render Pipeline Layout"),
bind_group_layouts: &[
Some(&texture_group_bind_layout),
Some(&camera_bind_group_layout),
],
immediate_size: 0,
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Render Pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Vertex::desc(), InstanceRaw::desc()],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: target_format.clone(),
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent::REPLACE,
alpha: wgpu::BlendComponent::REPLACE,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
polygon_mode: wgpu::PolygonMode::Fill,
// Requires Features::DEPTH_CLIP_CONTROL
unclipped_depth: false,
// Requires Features::CONSERVATIVE_RASTERIZATION
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Vertex Buffer"),
contents: bytemuck::cast_slice(VERTICES),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Index Buffer"),
contents: bytemuck::cast_slice(INDICES),
usage: wgpu::BufferUsages::INDEX,
});
let num_indices = INDICES.len() as u32;
Self {
camera,
camera_uniform,
camera_buffer,
camera_bind_group,
diffuse_texture,
diffuse_bind_group,
render_pipeline,
vertex_buffer,
index_buffer,
num_indices,
instance: instances,
instance_buffer,
cols: Some(cols),
rows: Some(rows),
}
}
// fn with_dot_matrix()
fn prepare(&mut self, queue: &wgpu::Queue, aspect: f32) {
self.camera.aspect = aspect;
self.camera_uniform.update_view_proj(&self.camera);
queue.write_buffer(
&self.camera_buffer,
0,
bytemuck::cast_slice(&[self.camera_uniform]),
);
}
fn paint(&self, render_pass: &mut wgpu::RenderPass<'_>) {
// TODO: set pipeline / bind groups / buffers and draw the model viewport here.
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_bind_group(0, &self.diffuse_bind_group, &[]);
render_pass.set_bind_group(1, &self.camera_bind_group, &[]);
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.set_vertex_buffer(1, self.instance_buffer.slice(..));
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
// UPDATED!
render_pass.draw_indexed(0..self.num_indices, 0, 0..self.instance.len() as _);
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 3],
tex_coords: [f32; 2],
}
impl Vertex {
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
use core::mem;
wgpu::VertexBufferLayout {
array_stride: mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
},
wgpu::VertexAttribute {
offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
shader_location: 1,
format: wgpu::VertexFormat::Float32x2,
},
],
}
}
}
const VERTICES: &[Vertex] = &[
Vertex {
position: [-0.0868241, 0.49240386, 0.0],
tex_coords: [0.4131759, 0.00759614],
}, // A
Vertex {
position: [-0.49513406, 0.06958647, 0.0],
tex_coords: [0.0048659444, 0.43041354],
}, // B
Vertex {
position: [-0.21918549, -0.44939706, 0.0],
tex_coords: [0.28081453, 0.949397],
}, // C
Vertex {
position: [0.35966998, -0.3473291, 0.0],
tex_coords: [0.85967, 0.84732914],
}, // D
Vertex {
position: [0.44147372, 0.2347359, 0.0],
tex_coords: [0.9414737, 0.2652641],
}, // E
];
const INDICES: &[u16] = &[0, 1, 4, 1, 2, 4, 2, 3, 4];
struct Camera {
eye: glam::Vec3,
target: glam::Vec3,
up: glam::Vec3,
aspect: f32,
fovy: f32,
znear: f32,
zfar: f32,
}
impl Camera {
fn build_view_projection_matrix(&self) -> glam::Mat4 {
let view = glam::Mat4::look_at_rh(self.eye, self.target, self.up);
let projection =
glam::Mat4::perspective_rh(self.fovy.to_radians(), self.aspect, self.znear, self.zfar);
projection * view
}
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct CameraUniform {
view_proj: [[f32; 4]; 4],
}
impl CameraUniform {
fn new() -> Self {
Self {
view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
}
}
fn update_view_proj(&mut self, camera: &Camera) {
self.view_proj = camera.build_view_projection_matrix().to_cols_array_2d();
}
}
struct Instance {
position: glam::Vec3,
rotation: glam::Quat,
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct InstanceRaw {
model: [[f32; 4]; 4],
}
impl Instance {
fn to_raw(&self) -> InstanceRaw {
InstanceRaw {
model: (glam::Mat4::from_translation(self.position)
* glam::Mat4::from_quat(self.rotation))
.to_cols_array_2d(),
}
}
}
impl InstanceRaw {
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
use core::mem;
wgpu::VertexBufferLayout {
array_stride: mem::size_of::<InstanceRaw>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 5,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: core::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress,
shader_location: 6,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: core::mem::size_of::<[f32; 8]>() as wgpu::BufferAddress,
shader_location: 7,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: core::mem::size_of::<[f32; 12]>() as wgpu::BufferAddress,
shader_location: 8,
format: wgpu::VertexFormat::Float32x4,
},
],
}
}
}

110
src/matrix.rs Normal file
View File

@@ -0,0 +1,110 @@
pub const MATRIX_ROWS: u32 = 12;
pub const MATRIX_COLS: u32 = 7;
const BASE_MATRIX_SPAN: f32 = 24.0;
const MATRIX_SPAN_GROWTH: f32 = 0.6;
const MIN_MATRIX_SPAN: f32 = 24.0;
const MAX_MATRIX_SPAN: f32 = 58.0;
const MIN_CELL_SPACING: f32 = 0.52;
const MAX_CELL_SPACING: f32 = 3.8;
const MIN_BOARD_PADDING: f32 = 2.6;
const MAX_BOARD_PADDING: f32 = 6.8;
const MATRIX_OFFSET_Y: f32 = -2.4;
const MATRIX_OFFSET_Z: f32 = 12.0;
const CAMERA_FOV: f32 = 36.0;
const CAMERA_DISTANCE_MIN: f32 = 30.0;
const CAMERA_DISTANCE_MAX: f32 = 122.0;
const CAMERA_FIT_PADDING: f32 = 1.04;
const CAMERA_ELEVATION_DEG: f32 = 64.0;
const CAMERA_TARGET_X: f32 = 0.0;
const CAMERA_TARGET_Y: f32 = MATRIX_OFFSET_Y + 0.2;
const CAMERA_TARGET_Z: f32 = MATRIX_OFFSET_Z - 0.4;
pub struct MatrixLayout {
pub cell_spacing: f32,
pub board_width: f32,
pub board_depth: f32,
pub board_padding: f32,
pub label_float_offset: f32,
}
impl MatrixLayout {
pub fn new(rows: u32, cols: u32) -> Self {
let longest_edge = rows.max(cols).max(1) as f32;
let edge_span = (longest_edge - 1.0).max(1.0);
let target_span = (BASE_MATRIX_SPAN + edge_span * MATRIX_SPAN_GROWTH)
.clamp(MIN_MATRIX_SPAN, MAX_MATRIX_SPAN);
let cell_spacing = (target_span / edge_span).clamp(MIN_CELL_SPACING, MAX_CELL_SPACING);
let board_width = cols.max(1) as f32 * cell_spacing;
let board_depth = rows.max(1) as f32 * cell_spacing;
let board_padding = (cell_spacing * 1.62).clamp(MIN_BOARD_PADDING, MAX_BOARD_PADDING);
let label_float_offset = (cell_spacing * 0.42).clamp(0.36, 1.12);
Self {
cell_spacing,
board_width,
board_depth,
board_padding,
label_float_offset,
}
}
}
pub fn build_view_projection(aspect: f32, layout: &MatrixLayout) -> [[f32; 4]; 4] {
let camera_distance = fit_camera_distance(
layout.board_width,
layout.board_depth,
layout.board_padding,
aspect,
);
let elevation = CAMERA_ELEVATION_DEG.to_radians();
let target = glam::Vec3::new(CAMERA_TARGET_X, CAMERA_TARGET_Y, CAMERA_TARGET_Z);
let eye = glam::Vec3::new(
CAMERA_TARGET_X,
CAMERA_TARGET_Y + elevation.sin() * camera_distance,
CAMERA_TARGET_Z + elevation.cos() * camera_distance,
);
let view = glam::Mat4::look_at_rh(eye, target, glam::Vec3::Y);
let projection = glam::Mat4::perspective_rh(CAMERA_FOV.to_radians(), aspect, 0.1, 500.0);
let open_gl_to_wgpu = glam::Mat4::from_cols_array(&[
1.0, 0.0, 0.0, 0.0, //
0.0, 1.0, 0.0, 0.0, //
0.0, 0.0, 0.5, 0.0, //
0.0, 0.0, 0.5, 1.0,
]);
(open_gl_to_wgpu * projection * view).to_cols_array_2d()
}
pub fn glyph_world_position(
row: u32,
col: u32,
rows: u32,
cols: u32,
layout: &MatrixLayout,
pressure: f32,
) -> ([f32; 4], f32) {
let normalized = pressure.clamp(0.0, 1.0);
let x = (col as f32 - cols as f32 / 2.0 + 0.5) * layout.cell_spacing;
let z = (row as f32 - rows as f32 / 2.0 + 0.5) * layout.cell_spacing;
(
[
x,
MATRIX_OFFSET_Y + layout.label_float_offset,
MATRIX_OFFSET_Z + z,
1.0,
],
normalized,
)
}
fn fit_camera_distance(board_width: f32, board_depth: f32, board_padding: f32, aspect: f32) -> f32 {
let padded_width = board_width + board_padding * 2.0;
let padded_depth = board_depth + board_padding * 2.0;
let safe_aspect = aspect.max(0.5);
let effective_half_span = (padded_depth * 0.5).max((padded_width * 0.5) / safe_aspect);
let fov_radians = (CAMERA_FOV * 0.5).to_radians();
let fit_distance = (effective_half_span / fov_radians.tan()) * CAMERA_FIT_PADDING;
fit_distance.clamp(CAMERA_DISTANCE_MIN, CAMERA_DISTANCE_MAX)
}

367
src/render.rs Normal file
View File

@@ -0,0 +1,367 @@
use eframe::{
egui,
egui_wgpu::{self, wgpu},
wgpu::util::DeviceExt,
};
use crate::matrix::{MatrixLayout, build_view_projection, glyph_world_position};
pub const PRESSURE_CELL_COUNT: usize =
(crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize;
pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT];
pub struct WgpuBackgroundCallback {
pub width: f32,
pub height: f32,
pub pressure: PressureFrame,
}
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
fn prepare(
&self,
_device: &wgpu::Device,
queue: &wgpu::Queue,
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
_egui_encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
resources.prepare(queue, self.width, self.height, &self.pressure);
Vec::new()
}
fn paint(
&self,
_info: egui::PaintCallbackInfo,
render_pass: &mut wgpu::RenderPass<'static>,
resources: &egui_wgpu::CallbackResources,
) {
let resources: &BackgroundRenderResources = resources.get().unwrap();
resources.paint(render_pass);
}
}
pub struct BackgroundRenderResources {
layout: MatrixLayout,
rows: u32,
cols: u32,
uniform: MatrixUniform,
uniform_buffer: wgpu::Buffer,
uniform_bind_group: wgpu::BindGroup,
background_pipeline: wgpu::RenderPipeline,
glyph_pipeline: wgpu::RenderPipeline,
glyph_vertex_buffer: wgpu::Buffer,
glyph_instance_buffer: wgpu::Buffer,
glyph_instances: Vec<GlyphInstance>,
}
impl BackgroundRenderResources {
pub fn new(
device: &wgpu::Device,
target_format: &wgpu::TextureFormat,
rows: u32,
cols: u32,
) -> Self {
let layout = MatrixLayout::new(rows, cols);
let uniform = MatrixUniform::new(1.0, 1.0, build_view_projection(1.0, &layout));
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Pressure Matrix Uniform Buffer"),
contents: bytemuck::cast_slice(&[uniform]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("pressure_matrix_bind_group_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("pressure_matrix_bind_group"),
layout: &uniform_bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
}],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Pressure Matrix Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Pressure Matrix Pipeline Layout"),
bind_group_layouts: &[Some(&uniform_bind_group_layout)],
immediate_size: 0,
});
let background_pipeline =
create_background_pipeline(device, target_format, &shader, &pipeline_layout);
let glyph_pipeline =
create_glyph_pipeline(device, target_format, &shader, &pipeline_layout);
let glyph_vertices = [
GlyphVertex {
local: [-1.0, -1.0],
},
GlyphVertex { local: [1.0, -1.0] },
GlyphVertex { local: [-1.0, 1.0] },
GlyphVertex { local: [-1.0, 1.0] },
GlyphVertex { local: [1.0, -1.0] },
GlyphVertex { local: [1.0, 1.0] },
];
let glyph_vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Pressure Glyph Vertex Buffer"),
contents: bytemuck::cast_slice(&glyph_vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let glyph_instances =
build_glyph_instances(rows, cols, &layout, &[[0.0, 0.0]; PRESSURE_CELL_COUNT]);
let glyph_instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Pressure Glyph Instance Buffer"),
contents: bytemuck::cast_slice(&glyph_instances),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
Self {
layout,
rows,
cols,
uniform,
uniform_buffer,
uniform_bind_group,
background_pipeline,
glyph_pipeline,
glyph_vertex_buffer,
glyph_instance_buffer,
glyph_instances,
}
}
fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &PressureFrame) {
let aspect = width / height.max(1.0);
self.uniform =
MatrixUniform::new(width, height, build_view_projection(aspect, &self.layout));
queue.write_buffer(
&self.uniform_buffer,
0,
bytemuck::cast_slice(&[self.uniform]),
);
update_glyph_instances(
&mut self.glyph_instances,
self.rows,
self.cols,
&self.layout,
pressure,
);
queue.write_buffer(
&self.glyph_instance_buffer,
0,
bytemuck::cast_slice(&self.glyph_instances),
);
}
fn paint(&self, render_pass: &mut wgpu::RenderPass<'_>) {
render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
render_pass.set_pipeline(&self.background_pipeline);
render_pass.draw(0..3, 0..1);
render_pass.set_pipeline(&self.glyph_pipeline);
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
render_pass.set_vertex_buffer(1, self.glyph_instance_buffer.slice(..));
render_pass.draw(0..6, 0..self.glyph_instances.len() as u32);
}
}
fn create_background_pipeline(
device: &wgpu::Device,
target_format: &wgpu::TextureFormat,
shader: &wgpu::ShaderModule,
layout: &wgpu::PipelineLayout,
) -> wgpu::RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Pressure Matrix Background Pipeline"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_background"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_background"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: *target_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
}
fn create_glyph_pipeline(
device: &wgpu::Device,
target_format: &wgpu::TextureFormat,
shader: &wgpu::ShaderModule,
layout: &wgpu::PipelineLayout,
) -> wgpu::RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Pressure Matrix Glyph Pipeline"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_glyph"),
compilation_options: Default::default(),
buffers: &[GlyphVertex::desc(), GlyphInstance::desc()],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_glyph"),
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_glyph_instances(
rows: u32,
cols: u32,
layout: &MatrixLayout,
pressure: &PressureFrame,
) -> Vec<GlyphInstance> {
let mut instances = Vec::with_capacity((rows * cols) as usize);
for row in 0..rows {
for col in 0..cols {
let index = (row * cols + col) as usize;
let [normalized, display_value] = pressure.get(index).copied().unwrap_or([0.0, 0.0]);
let (world_position, normalized) =
glyph_world_position(row, col, rows, cols, layout, normalized);
instances.push(GlyphInstance {
world_position,
style: [normalized, display_value, 0.0, 0.0],
});
}
}
instances
}
fn update_glyph_instances(
instances: &mut [GlyphInstance],
rows: u32,
cols: u32,
layout: &MatrixLayout,
pressure: &PressureFrame,
) {
for row in 0..rows {
for col in 0..cols {
let index = (row * cols + col) as usize;
let [normalized, display_value] = pressure.get(index).copied().unwrap_or([0.0, 0.0]);
let (world_position, normalized) =
glyph_world_position(row, col, rows, cols, layout, normalized);
if let Some(instance) = instances.get_mut(index) {
instance.world_position = world_position;
instance.style = [normalized, display_value, 0.0, 0.0];
}
}
}
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct MatrixUniform {
view_proj: [[f32; 4]; 4],
viewport: [f32; 4],
glyph: [f32; 4],
color: [f32; 4],
}
impl MatrixUniform {
fn new(width: f32, height: f32, view_proj: [[f32; 4]; 4]) -> Self {
Self {
view_proj,
viewport: [width.max(1.0), height.max(1.0), 0.0, 0.0],
glyph: [16.0, 0.0, 0.0, 0.0],
color: [0.05, 0.92, 0.32, 1.0],
}
}
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct GlyphVertex {
local: [f32; 2],
}
impl GlyphVertex {
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<GlyphVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2,
}],
}
}
}
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct GlyphInstance {
world_position: [f32; 4],
style: [f32; 4],
}
impl GlyphInstance {
fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<GlyphInstance>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 1,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress,
shader_location: 2,
format: wgpu::VertexFormat::Float32x4,
},
],
}
}
}

7
src/serial_core/codec.rs Normal file
View File

@@ -0,0 +1,7 @@
use crate::serial_core::error::CodecError;
use std::time::Instant;
pub trait Codec<F> {
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<F>, CodecError>;
fn encode(&self, frame: &F) -> Result<Vec<u8>, CodecError>;
}

View File

@@ -0,0 +1 @@
pub mod tactile_a;

View File

@@ -0,0 +1,184 @@
use crate::serial_core::codec::Codec;
use crate::serial_core::error::CodecError;
use crate::serial_core::frame::{
TactileAFrame, TactileAFrameMetaData, TactileAFrameStatusCode, TactileARepFrame,
TactileAReqFrame,
};
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
pub struct TactileACodec {
buffer: Vec<u8>,
expected_data_len: usize,
}
impl TactileACodec {
pub fn new(cols: usize, rows: usize) -> TactileACodec {
Self {
buffer: Vec::new(),
expected_data_len: cols * rows * 2,
}
}
pub fn parse_data_frame(data: &[u8]) -> Result<Vec<i32>, CodecError> {
if data.len() % 2 != 0 {
return Err(CodecError::InvalidLength);
}
let vals: Vec<i32> = data
.chunks_exact(2)
.map(|chunk| {
let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32;
if raw < 15 { 0 } else { raw }
})
.collect::<Vec<i32>>();
Ok(vals)
}
pub fn build_req_frame(cols: usize, rows: usize) -> TactileAFrame {
let header = [0x55, 0xAA];
let payload_len: usize = 9;
let device_addr: u8 = 0x34;
let extend_code: u8 = 0x00;
let func_code: u8 = 0xFB;
let start_addr: u32 = 7168;
let except_data_len: usize = cols * rows * 2;
let checksum: u8 = 0;
TactileAFrame::Req(TactileAReqFrame {
meta: TactileAFrameMetaData {
header,
payload_len,
device_addr,
extend_code,
func_code,
start_addr,
except_data_len,
checksum,
},
})
}
}
impl Codec<TactileAFrame> for TactileACodec {
fn decode(
&mut self,
input: &[u8],
session_started_at: std::time::Instant,
) -> Result<Vec<TactileAFrame>, CodecError> {
self.buffer.extend_from_slice(input);
let mut frames: Vec<TactileAFrame> = Vec::new();
loop {
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH {
break;
}
// Search for response header: [0xAA, 0x55]
let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]);
let Some(pos) = header_pos else {
self.buffer.clear();
break;
};
if pos > 0 {
self.buffer.drain(0..pos);
}
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH {
break;
}
let header = [self.buffer[0], self.buffer[1]];
let payload_len = u16::from_le_bytes([self.buffer[2], self.buffer[3]]) as usize;
let device_addr = self.buffer[4];
let extend_code = self.buffer[5];
let func_code = self.buffer[6];
let start_addr = u32::from_le_bytes([
self.buffer[7],
self.buffer[8],
self.buffer[9],
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,
};
if except_data_len != self.expected_data_len {
log::debug!(
"unexpected payload length: expected {}, got {}, buffer_len={}",
self.expected_data_len,
except_data_len,
self.buffer.len()
);
self.buffer.drain(0..1);
continue;
}
let frame_length = except_data_len + FRAME_BUFFER_MIN_LENGTH;
if self.buffer.len() < frame_length {
break;
}
let need_check_data = self.buffer[0..14 + except_data_len].to_vec();
let payload = self.buffer[14..14 + except_data_len].to_vec();
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
let checksum = crc8_itu_alg.checksum(&need_check_data.as_slice());
if self.buffer[frame_length - 1] != checksum {
log::debug!(
"checksum mismatch: expected {:02X}, got {:02X}, frame_len={}",
checksum,
self.buffer[frame_length - 1],
frame_length
);
self.buffer.drain(0..1);
continue;
}
let dts_ms = elapsed_millis(session_started_at);
let meta = TactileAFrameMetaData {
header,
payload_len,
device_addr,
extend_code,
func_code,
start_addr,
except_data_len,
checksum,
};
frames.push(TactileAFrame::Rep(TactileARepFrame {
meta,
status,
payload,
dts_ms,
}));
self.buffer.drain(0..frame_length);
}
Ok(frames)
}
fn encode(&self, frame: &TactileAFrame) -> Result<Vec<u8>, CodecError> {
match frame {
TactileAFrame::Req(f) => {
let mut req_bytes: Vec<u8> = Vec::new();
req_bytes.extend_from_slice(f.meta.header.as_slice());
req_bytes.extend_from_slice((f.meta.payload_len as u16).to_le_bytes().as_slice());
req_bytes.push(f.meta.device_addr);
req_bytes.push(f.meta.extend_code);
req_bytes.push(f.meta.func_code);
req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice());
req_bytes
.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice());
let checksum = calc_crc8_itu(req_bytes.as_slice());
req_bytes.push(checksum);
Ok(req_bytes)
}
_ => Err(CodecError::InvalidFrameType),
}
}
}

51
src/serial_core/error.rs Normal file
View File

@@ -0,0 +1,51 @@
use std::fmt;
#[derive(Debug)]
pub enum SerialError {
OpenError,
CloseError,
ScanError,
InvalidConfig,
AlreadyConnected,
StateError,
NoRecordedData,
}
impl fmt::Display for SerialError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
SerialError::OpenError => write!(f, "Opening Error"),
SerialError::CloseError => write!(f, "Closing Error"),
SerialError::ScanError => write!(f, "Scan Error"),
SerialError::InvalidConfig => write!(f, "Invalid Config"),
SerialError::AlreadyConnected => write!(f, "Already Connected"),
SerialError::StateError => write!(f, "State Error"),
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
}
}
}
impl std::error::Error for SerialError {}
#[derive(Debug)]
pub enum CodecError {
InvalidHeader,
InvalidTail,
InvalidLength,
InvalidFrameType,
PayloadTooLarge,
}
impl fmt::Display for CodecError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CodecError::InvalidHeader => write!(f, "Invalid Header"),
CodecError::InvalidTail => write!(f, "Invalid Tail"),
CodecError::InvalidLength => write!(f, "Invalid Length"),
CodecError::InvalidFrameType => write!(f, "Invalid Frame Type"),
CodecError::PayloadTooLarge => write!(f, "Payload too large"),
}
}
}
impl std::error::Error for CodecError {}

36
src/serial_core/frame.rs Normal file
View File

@@ -0,0 +1,36 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TactileAFrameMetaData {
pub header: [u8; 2],
pub payload_len: usize,
pub device_addr: u8,
pub extend_code: u8,
pub func_code: u8,
pub start_addr: u32,
pub except_data_len: usize,
pub checksum: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TactileAReqFrame {
pub meta: TactileAFrameMetaData,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TactileARepFrame {
pub meta: TactileAFrameMetaData,
pub status: TactileAFrameStatusCode,
pub payload: Vec<u8>,
pub dts_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TactileAFrameStatusCode {
Success,
Failure,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TactileAFrame {
Req(TactileAReqFrame),
Rep(TactileARepFrame),
}

6
src/serial_core/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod codec;
pub mod codecs;
pub mod error;
pub mod frame;
pub mod serial;
pub mod utils;

96
src/serial_core/serial.rs Normal file
View File

@@ -0,0 +1,96 @@
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 std::io::{Read, Write};
use std::time::{Duration, Instant};
const POLL_INTERVAL_MS: u64 = 10;
/// 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(
port: &mut dyn ReadWrite,
rows: usize,
cols: usize,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
) {
let session_started_at = Instant::now();
let mut codec = TactileACodec::new(cols, rows);
let req_frame = TactileACodec::build_req_frame(cols, rows);
let mut buffer = [0u8; 1024];
let mut poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
loop {
// Check cancel
if cancel_rx.try_recv().is_ok() {
break;
}
// Send poll request
if let Ok(req_bytes) = codec.encode(&req_frame) {
let _ = port.write_all(&req_bytes);
}
// Read response with poll interval
let deadline = Instant::now() + poll_interval;
loop {
if Instant::now() >= deadline {
break;
}
match port.read(&mut buffer) {
Ok(n) if n > 0 => {
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
for frame in frames {
if let TactileAFrame::Rep(rep) = frame {
if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) {
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] read error: {e}");
return;
}
}
}
}
}
/// Trait abstracting read+write for the serial port
pub trait ReadWrite: Send {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()>;
}
/// Wrapper for serialport's Box<dyn SerialPort>
pub struct SerialPortReadWrite {
inner: Box<dyn serialport::SerialPort>,
}
impl SerialPortReadWrite {
pub fn new(port: Box<dyn serialport::SerialPort>) -> Self {
Self { inner: port }
}
}
impl ReadWrite for SerialPortReadWrite {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
self.inner.write_all(buf)
}
}

10
src/serial_core/utils.rs Normal file
View File

@@ -0,0 +1,10 @@
use std::time::Instant;
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)
}
pub fn elapsed_millis(start_at: Instant) -> u64 {
start_at.elapsed().as_millis() as u64
}

View File

@@ -1,52 +1,258 @@
// 顶点着色器
struct Camera {
struct MatrixUniform {
view_proj: mat4x4f,
}
@group(1) @binding(0)
var<uniform> camera: Camera;
struct VertexInput {
@location(0) position: vec3f,
@location(1) tex_coords: vec2f,
}
struct InstanceInput {
@location(5) model_matrix_0: vec4f,
@location(6) model_matrix_1: vec4f,
@location(7) model_matrix_2: vec4f,
@location(8) model_matrix_3: vec4f,
viewport: vec4f,
glyph: vec4f,
color: vec4f,
}
struct VertexOutput {
@group(0) @binding(0)
var<uniform> u: MatrixUniform;
struct BackgroundVertexOutput {
@builtin(position) clip_position: vec4f,
@location(0) tex_coords: vec2f,
}
struct GlyphVertexInput {
@location(0) local: vec2f,
}
struct GlyphInstanceInput {
@location(1) world_position: vec4f,
@location(2) style: vec4f,
}
struct GlyphVertexOutput {
@builtin(position) clip_position: vec4f,
@location(0) local: vec2f,
@location(1) intensity: f32,
@location(2) display_value: f32,
}
fn saturate(value: f32) -> f32 {
return clamp(value, 0.0, 1.0);
}
fn capsule_distance(point: vec2f, start: vec2f, end: vec2f) -> f32 {
let segment = end - start;
let h = saturate(dot(point - start, segment) / dot(segment, segment));
return length(point - start - segment * h);
}
fn rotate2(point: vec2f, angle: f32) -> vec2f {
let s = sin(angle);
let c = cos(angle);
return vec2f(point.x * c - point.y * s, point.x * s + point.y * c);
}
fn rect_alpha(point: vec2f, center: vec2f, half_size: vec2f) -> f32 {
let delta = abs(point - center) - half_size;
let outside = length(max(delta, vec2f(0.0, 0.0)));
let inside = min(max(delta.x, delta.y), 0.0);
let dist = outside + inside;
return 1.0 - smoothstep(0.015, 0.045, dist);
}
fn range_stop_color(index: u32) -> vec3f {
switch index {
case 0u: {
return vec3f(0.140, 0.690, 0.890);
}
case 1u: {
return vec3f(0.250, 0.760, 0.380);
}
case 2u: {
return vec3f(1.000, 0.670, 0.180);
}
default: {
return vec3f(1.000, 0.255, 0.190);
}
}
}
fn sample_range_color(value: f32) -> vec3f {
let t = saturate(value);
if (t <= 0.33) {
let local = smoothstep(0.0, 0.33, t);
return mix(range_stop_color(0u), range_stop_color(1u), local);
}
if (t <= 0.66) {
let local = smoothstep(0.33, 0.66, t);
return mix(range_stop_color(1u), range_stop_color(2u), local);
}
let local = smoothstep(0.66, 1.0, t);
return mix(range_stop_color(2u), range_stop_color(3u), local);
}
fn digit_segment_on(digit: u32, segment: u32) -> bool {
switch digit {
case 0u: { return segment != 6u; }
case 1u: { return segment == 1u || segment == 2u; }
case 2u: { return segment == 0u || segment == 1u || segment == 6u || segment == 4u || segment == 3u; }
case 3u: { return segment == 0u || segment == 1u || segment == 6u || segment == 2u || segment == 3u; }
case 4u: { return segment == 5u || segment == 6u || segment == 1u || segment == 2u; }
case 5u: { return segment == 0u || segment == 5u || segment == 6u || segment == 2u || segment == 3u; }
case 6u: { return segment == 0u || segment == 5u || segment == 4u || segment == 3u || segment == 2u || segment == 6u; }
case 7u: { return segment == 0u || segment == 1u || segment == 2u; }
case 8u: { return true; }
default: { return segment == 0u || segment == 1u || segment == 2u || segment == 3u || segment == 5u || segment == 6u; }
}
}
fn seven_segment_digit_alpha(local: vec2f, digit: u32) -> f32 {
var alpha = 0.0;
if (digit_segment_on(digit, 0u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.70), vec2f(0.38, 0.078)));
}
if (digit_segment_on(digit, 1u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.39, 0.36), vec2f(0.078, 0.335)));
}
if (digit_segment_on(digit, 2u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.39, -0.36), vec2f(0.078, 0.335)));
}
if (digit_segment_on(digit, 3u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, -0.70), vec2f(0.38, 0.078)));
}
if (digit_segment_on(digit, 4u)) {
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, -0.36), vec2f(0.078, 0.335)));
}
if (digit_segment_on(digit, 5u)) {
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, 0.36), vec2f(0.078, 0.335)));
}
if (digit_segment_on(digit, 6u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.0), vec2f(0.35, 0.075)));
}
return alpha;
}
fn digit_count(value: u32) -> u32 {
if (value >= 1000u) {
return 4u;
}
if (value >= 100u) {
return 3u;
}
if (value >= 10u) {
return 2u;
}
return 1u;
}
fn digit_at(value: u32, slot: u32, count: u32) -> u32 {
if (count == 4u) {
switch slot {
case 0u: { return (value / 1000u) % 10u; }
case 1u: { return (value / 100u) % 10u; }
case 2u: { return (value / 10u) % 10u; }
default: { return value % 10u; }
}
}
if (count == 3u) {
switch slot {
case 0u: { return (value / 100u) % 10u; }
case 1u: { return (value / 10u) % 10u; }
default: { return value % 10u; }
}
}
if (count == 2u) {
return select(value % 10u, (value / 10u) % 10u, slot == 0u);
}
return value % 10u;
}
fn number_alpha(local: vec2f, display_value: f32) -> f32 {
let value = min(u32(max(display_value + 0.5, 0.0)), 9999u);
let count = digit_count(value);
let count_f = f32(count);
let slot_width = 1.74 / count_f;
let start_x = -slot_width * (count_f - 1.0) * 0.5;
var alpha = 0.0;
for (var slot = 0u; slot < 4u; slot = slot + 1u) {
if (slot < count) {
let center_x = start_x + f32(slot) * slot_width;
let digit_local = vec2f((local.x - center_x) / (slot_width * 0.78), local.y / 0.92);
let digit = digit_at(value, slot, count);
let in_slot = step(abs(local.x - center_x), slot_width * 0.48);
alpha = max(alpha, seven_segment_digit_alpha(digit_local, digit) * in_slot);
}
}
return alpha;
}
@vertex
fn vs_main(
model: VertexInput,
instance: InstanceInput,
) -> VertexOutput {
let model_matrix = mat4x4f(
instance.model_matrix_0,
instance.model_matrix_1,
instance.model_matrix_2,
instance.model_matrix_3,
fn vs_background(@builtin(vertex_index) vertex_index: u32) -> BackgroundVertexOutput {
let positions = array<vec2f, 3>(
vec2f(-1.0, -3.0),
vec2f(3.0, 1.0),
vec2f(-1.0, 1.0),
);
var out: VertexOutput;
out.tex_coords = model.tex_coords;
out.clip_position = camera.view_proj * model_matrix * vec4f(model.position, 1.0);
var out: BackgroundVertexOutput;
out.clip_position = vec4f(positions[vertex_index], 0.0, 1.0);
return out;
}
// 片元着色器
@fragment
fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {
let pixel = frag_coord.xy;
let viewport = u.viewport.xy;
let uv = pixel / max(viewport, vec2f(1.0, 1.0));
var color = mix(vec3f(0.112, 0.126, 0.160), vec3f(0.145, 0.160, 0.198), 1.0 - uv.y);
let vignette = smoothstep(0.18, 0.92, length((uv - vec2f(0.52, 0.48)) * vec2f(viewport.x / viewport.y, 1.0)));
color *= 1.0 - vignette * 0.30;
color += vec3f(0.035, 0.070, 0.090) * (1.0 - smoothstep(0.0, 0.9, abs(uv.y - 0.52)));
@group(0) @binding(0)
var t_diffuse: texture_2d<f32>;
@group(0)@binding(1)
var s_diffuse: sampler;
let track_width = clamp(viewport.x * 0.42, 260.0, 560.0);
let track_height = 12.0;
let track_center = vec2f(viewport.x * 0.5, viewport.y - 38.0);
let local = pixel - track_center;
let half_size = vec2f(track_width * 0.5, track_height * 0.5);
let inside_track = step(abs(local.x), half_size.x) * step(abs(local.y), half_size.y);
let border = step(abs(local.x), half_size.x + 1.0) * step(abs(local.y), half_size.y + 1.0) - inside_track;
let t = saturate((local.x + half_size.x) / track_width);
if (inside_track > 0.5) {
let shade = mix(0.72, 1.0, 1.0 - saturate((local.y + half_size.y) / track_height));
color = mix(color, sample_range_color(t) * shade, 0.96);
}
color = max(color, vec3f(0.34, 0.41, 0.46) * border);
let tick_area = step(abs(local.x), half_size.x) * step(abs(local.y), half_size.y + 8.0);
if (tick_area > 0.5) {
let ticks = array<f32, 4>(0.0, 0.33, 0.66, 1.0);
for (var index = 0u; index < 4u; index = index + 1u) {
let tick_x = (ticks[index] - 0.5) * track_width;
let tick = step(abs(local.x - tick_x), 1.0) * step(abs(local.y), half_size.y + 7.0);
color = max(color, vec3f(0.78, 0.84, 0.90) * tick);
}
}
return vec4f(color, 1.0);
}
@vertex
fn vs_glyph(vertex: GlyphVertexInput, instance: GlyphInstanceInput) -> GlyphVertexOutput {
let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0);
let shaped = pow(saturate(instance.style.x), 0.9);
let pixel_size = u.glyph.x * mix(1.08, 2.20, shaped);
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
var out: GlyphVertexOutput;
out.clip_position = vec4f(center.xy + ndc_offset * center.w, center.z, center.w);
out.local = vertex.local;
out.intensity = instance.style.x;
out.display_value = instance.style.y;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4f {
return textureSample(t_diffuse, s_diffuse, in.tex_coords);
}
fn fs_glyph(in: GlyphVertexOutput) -> @location(0) vec4f {
let alpha = number_alpha(in.local, in.display_value);
let color = sample_range_color(in.intensity) * mix(0.82, 1.16, saturate(in.intensity));
return vec4f(color, alpha);
}

161
src/theme.rs Normal file
View File

@@ -0,0 +1,161 @@
use eframe::egui;
#[derive(Clone, Copy)]
pub struct AppTheme {
pub bg: egui::Color32,
pub panel: egui::Color32,
pub panel_strong: egui::Color32,
pub panel_deep: egui::Color32,
pub border: egui::Color32,
pub border_soft: egui::Color32,
pub text: egui::Color32,
pub text_dim: egui::Color32,
pub accent: egui::Color32,
pub accent_hot: egui::Color32,
pub radius: u8,
}
pub const ENGINEERING_DARK: AppTheme = AppTheme {
bg: egui::Color32::from_rgb(33, 35, 44),
panel: egui::Color32::from_rgb(25, 36, 48),
panel_strong: egui::Color32::from_rgb(47, 58, 70),
panel_deep: egui::Color32::from_rgb(14, 18, 24),
border: egui::Color32::from_rgb(53, 75, 92),
border_soft: egui::Color32::from_rgb(36, 53, 66),
text: egui::Color32::from_rgb(242, 246, 252),
text_dim: egui::Color32::from_rgb(206, 216, 230),
accent: egui::Color32::from_rgb(255, 118, 47),
accent_hot: egui::Color32::from_rgb(255, 169, 77),
radius: 2,
};
pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
let mut visuals = egui::Visuals::dark();
visuals.override_text_color = Some(theme.text);
visuals.panel_fill = theme.bg;
visuals.window_fill = theme.panel;
visuals.window_stroke = egui::Stroke::new(1.0, theme.border);
visuals.extreme_bg_color = theme.panel_deep;
visuals.faint_bg_color = theme.panel_strong;
visuals.code_bg_color = theme.panel_deep;
visuals.warn_fg_color = theme.accent_hot;
visuals.error_fg_color = egui::Color32::from_rgb(255, 98, 82);
visuals.widgets.noninteractive.bg_fill = theme.panel_strong;
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, theme.text);
visuals.widgets.inactive.bg_fill = theme.panel_strong;
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, theme.text);
visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(58, 69, 82);
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, theme.border);
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme.text);
visuals.widgets.active.bg_fill = theme.accent;
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, theme.accent_hot);
visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
visuals.widgets.open.bg_fill = egui::Color32::from_rgb(43, 55, 68);
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, theme.border);
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, theme.text);
visuals.selection.bg_fill = theme.accent;
visuals.selection.stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
visuals.hyperlink_color = theme.accent_hot;
ctx.set_visuals(visuals);
let mut style = (*ctx.global_style()).clone();
style.spacing.item_spacing = egui::vec2(7.0, 5.0);
style.spacing.window_margin = egui::Margin::same(7);
style.visuals.window_corner_radius = egui::CornerRadius::same(theme.radius);
style.visuals.menu_corner_radius = egui::CornerRadius::same(2);
style.visuals.window_shadow = egui::epaint::Shadow {
offset: [0, 12],
blur: 24,
spread: 0,
color: egui::Color32::from_black_alpha(110),
};
ctx.set_global_style(style);
}
pub fn apply_fonts(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"Hack-Bold".to_owned(),
egui::FontData::from_static(include_bytes!("../static/Hack-Bold.ttf")).into(),
);
let has_yahei = std::fs::read(r"C:\Windows\Fonts\msyh.ttc")
.or_else(|_| std::fs::read(r"C:\Windows\Fonts\msyhbd.ttc"))
.map(|font_data| {
fonts.font_data.insert(
"Microsoft-YaHei".to_owned(),
egui::FontData::from_owned(font_data).into(),
);
})
.is_ok();
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "Hack-Bold".to_owned());
if has_yahei {
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.push("Microsoft-YaHei".to_owned());
}
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.insert(0, "Hack-Bold".to_owned());
if has_yahei {
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.push("Microsoft-YaHei".to_owned());
}
ctx.set_fonts(fonts);
}
pub fn panel_frame(ctx: &egui::Context) -> egui::Frame {
let style = ctx.global_style();
egui::Frame::window(&style)
.fill(ENGINEERING_DARK.panel)
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
.corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius))
.shadow(egui::epaint::Shadow {
offset: [0, 12],
blur: 26,
spread: 0,
color: egui::Color32::from_black_alpha(120),
})
}
pub fn group_frame() -> egui::Frame {
egui::Frame::new()
.fill(ENGINEERING_DARK.panel_deep)
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft))
.corner_radius(egui::CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(6, 5))
}
pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
egui::Button::new(label)
.fill(ENGINEERING_DARK.panel_strong)
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
.corner_radius(egui::CornerRadius::same(2))
}
pub fn dim_text() -> egui::Color32 {
ENGINEERING_DARK.text_dim
}
pub fn accent_text() -> egui::Color32 {
ENGINEERING_DARK.accent_hot
}

967
src/ui.rs Normal file
View File

@@ -0,0 +1,967 @@
use eframe::egui;
use crate::{
connection::{ConnectionManager, ConnectionState},
theme::{ENGINEERING_DARK, accent_text, dim_text, group_frame, panel_frame, tag_button},
utils::serial_enum,
};
pub struct FloatingPanelState {
pub visible: bool,
default_pos: egui::Pos2,
tag_pos: egui::Pos2,
center_anim: f32,
center_anim_target: bool,
center_anim_last_time: Option<f64>,
}
pub struct ConfigPanelState {
pub mode: SerialMode,
pub port: String,
pub baud_rate: u32,
pub data_bits: u8,
pub stop_bits: u8,
pub parity: Parity,
pub timeout_ms: u32,
pub module_addr: u8,
pub connected: bool,
pub auto_reconnect: bool,
pub manual_tx: String,
pub model_path: String,
}
pub struct ConnectPanelState {
pub mode: SerialMode,
pub port: Vec<String>,
pub selected_port: String,
pub duration: u8,
pub manual: bool,
pub rows: u8,
pub cols: u8,
pub connection: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SerialMode {
SingleModule,
Manual,
Model,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Parity {
None,
Odd,
Even,
}
pub enum IconButtonIcon<'a> {
Font(&'a str),
Png(egui::ImageSource<'a>),
}
impl FloatingPanelState {
pub fn new(default_pos: [f32; 2], tag_pos: [f32; 2]) -> Self {
Self {
visible: true,
default_pos: egui::pos2(default_pos[0], default_pos[1]),
tag_pos: egui::pos2(tag_pos[0], tag_pos[1]),
center_anim: 1.0,
center_anim_target: true,
center_anim_last_time: None,
}
}
}
impl Default for ConfigPanelState {
fn default() -> Self {
Self {
mode: SerialMode::SingleModule,
port: "COM3".to_owned(),
baud_rate: 115_200,
data_bits: 8,
stop_bits: 1,
parity: Parity::None,
timeout_ms: 1000,
module_addr: 1,
connected: false,
auto_reconnect: true,
manual_tx: "01 03 00 00 00 02".to_owned(),
model_path: "model/default.eskin".to_owned(),
}
}
}
impl Default for ConnectPanelState {
fn default() -> Self {
let port = serial_enum().unwrap();
let selected_port = port.first().cloned().unwrap_or_default();
Self {
mode: SerialMode::SingleModule,
port,
selected_port,
duration: 10,
manual: false,
rows: 12,
cols: 7,
connection: false,
}
}
}
pub fn draw_scene_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) {
draw_floating_panel(ctx, panel, "场景", "scene_panel", |ui| {
ui.horizontal(|ui| {
ui.colored_label(dim_text(), "视图");
let _ = ui.selectable_label(true, "");
let _ = ui.selectable_label(false, "三角形");
});
ui.separator();
group_frame().show(ui, |ui| {
ui.label("模型 / 材质 / 灯光");
ui.label("目标任务 64");
ui.label("缓存命中 100.0%");
});
});
}
pub fn draw_connect_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
config: &mut ConnectPanelState,
connection: &ConnectionManager,
) {
let conn_state = connection.state();
let is_connected = matches!(
conn_state,
ConnectionState::Connected | ConnectionState::Streaming
);
draw_center_floating_panel(ctx, panel, "connect_center_panel", 42.0, "连接", |ui| {
ui.set_min_width(320.0);
ui.vertical(|ui| {
let button_width = 96.0;
let gap = 8.0;
let total_width = button_width * 3.0 + gap * 2.0;
let left_space = ((ui.available_width() - total_width) * 0.5).max(0.0);
ui.horizontal(|ui| {
ui.add_space(left_space);
mode_button(ui, &mut config.mode, SerialMode::SingleModule, "单模块");
ui.add_space(gap);
mode_button(ui, &mut config.mode, SerialMode::Manual, "全手");
ui.add_space(gap);
mode_button(ui, &mut config.mode, SerialMode::Model, "模型");
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(10.0);
ui.add(
egui::Image::new(egui::include_image!("../static/cpu.png"))
.fit_to_exact_size(egui::vec2(72.0, 72.0)),
);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.colored_label(ENGINEERING_DARK.text, "串口");
egui::ComboBox::from_id_salt("connect_ports")
.width(130.0)
.selected_text(if config.selected_port.is_empty() {
"无可用串口".to_owned()
} else {
config.selected_port.clone()
})
.show_ui(ui, |ui| {
for port in &config.port {
let label = port.clone();
ui.selectable_value(
&mut config.selected_port,
port.clone(),
label,
);
}
});
if ui
.add(
egui::Button::new("")
.fill(ENGINEERING_DARK.panel_strong)
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft))
.min_size(egui::vec2(24.0, 20.0)),
)
.on_hover_text("刷新串口")
.clicked()
{
if let Ok(ports) = serial_enum() {
if !ports.contains(&config.selected_port) {
config.selected_port =
ports.first().cloned().unwrap_or_default();
}
config.port = ports;
}
}
ui.add_space(10.0);
ui.label("频率");
ui.add_sized(
egui::vec2(72.0, 20.0),
egui::DragValue::new(&mut config.duration).range(1..=120),
);
});
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.checkbox(&mut config.manual, "手动");
ui.add_enabled_ui(config.manual, |ui| {
ui.horizontal(|ui| {
ui.colored_label(ENGINEERING_DARK.text_dim, "");
ui.add_sized(
egui::vec2(48.0, 20.0),
egui::DragValue::new(&mut config.rows).range(1..=64),
);
ui.colored_label(ENGINEERING_DARK.text_dim, "");
ui.add_sized(
egui::vec2(48.0, 20.0),
egui::DragValue::new(&mut config.cols).range(1..=64),
);
});
});
});
})
});
ui.add_space(8.0);
// Connection status and button row
ui.horizontal(|ui| {
let status_text = match conn_state {
ConnectionState::Disconnected => "未连接",
ConnectionState::Connecting => "连接中...",
ConnectionState::Connected => "已连接",
ConnectionState::Streaming => "采集中",
ConnectionState::Error => "连接错误",
};
let status_color = match conn_state {
ConnectionState::Disconnected => ENGINEERING_DARK.text_dim,
ConnectionState::Connecting => egui::Color32::from_rgb(200, 180, 60),
ConnectionState::Connected => egui::Color32::from_rgb(158, 184, 101),
ConnectionState::Streaming => egui::Color32::from_rgb(100, 200, 255),
ConnectionState::Error => egui::Color32::from_rgb(255, 98, 82),
};
ui.colored_label(status_color, status_text);
let used = 100.0;
let remaining = (ui.available_width() - used).max(0.0);
ui.add_space(remaining);
let btn_label = if is_connected { "断开" } else { "连接" };
let btn_fill = if is_connected {
egui::Color32::from_rgb(180, 60, 60)
} else {
ENGINEERING_DARK.accent
};
let btn_stroke = if is_connected {
egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 80, 80))
} else {
egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot)
};
if ui
.add(
egui::Button::new(
egui::RichText::new(btn_label).color(egui::Color32::WHITE),
)
.fill(btn_fill)
.stroke(btn_stroke)
.min_size(egui::vec2(96.0, 28.0)),
)
.clicked()
{
if is_connected {
connection.disconnect();
} else if !config.selected_port.is_empty() {
connection.connect(
&config.selected_port,
config.rows as u32,
config.cols as u32,
);
}
}
});
});
});
}
pub fn draw_config_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
config: &mut ConfigPanelState,
) {
draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| {
ui.set_min_width(560.0);
draw_mode_row(ui, config);
ui.separator();
draw_connection_row(ui, config);
ui.add_space(8.0);
draw_serial_grid(ui, config);
ui.add_space(8.0);
draw_mode_body(ui, config);
});
}
fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
ui.horizontal(|ui| {
ui.colored_label(dim_text(), "模式");
ui.add_space(12.0);
mode_button(ui, &mut config.mode, SerialMode::SingleModule, "单模块");
mode_button(ui, &mut config.mode, SerialMode::Manual, "全手");
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(), "重连");
});
});
}
fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
ui.horizontal(|ui| {
ui.add(
egui::Image::new(egui::include_image!("../static/cpu.png"))
.fit_to_exact_size(egui::vec2(72.0, 72.0)),
);
ui.add_space(10.0);
ui.vertical(|ui| {
ui.label(format!("端口 {}", config.port));
ui.label(format!("波特率 {}", config.baud_rate));
let status = if config.connected {
"已连接"
} else {
"未连接"
};
let status_color = if config.connected {
egui::Color32::from_rgb(158, 184, 101)
} else {
egui::Color32::from_rgb(255, 98, 82)
};
ui.colored_label(status_color, status);
});
ui.add_space(22.0);
let button_text = if config.connected { "断开" } else { "连接" };
if ui
.add(
egui::Button::new(button_text)
.fill(ENGINEERING_DARK.accent)
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot))
.min_size(egui::vec2(120.0, 30.0)),
)
.clicked()
{
config.connected = !config.connected;
}
ui.add_space(18.0);
ui.colored_label(
egui::Color32::from_rgb(158, 184, 101),
if config.auto_reconnect {
"链路保护 开"
} else {
"链路保护 关"
},
);
});
}
fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
group_frame().show(ui, |ui| {
egui::Grid::new("serial_config_grid")
.num_columns(4)
.spacing(egui::vec2(10.0, 5.0))
.striped(true)
.show(ui, |ui| {
ui.label("端口");
ui.add_sized(
egui::vec2(110.0, 20.0),
egui::TextEdit::singleline(&mut config.port),
);
ui.label("波特率");
baud_combo(ui, config);
ui.end_row();
ui.label("数据位");
ui.add_sized(
egui::vec2(70.0, 20.0),
egui::DragValue::new(&mut config.data_bits).range(5..=8),
);
ui.label("校验");
parity_combo(ui, config);
ui.end_row();
ui.label("停止位");
ui.add_sized(
egui::vec2(70.0, 20.0),
egui::DragValue::new(&mut config.stop_bits).range(1..=2),
);
ui.label("超时");
ui.horizontal(|ui| {
ui.add_sized(
egui::vec2(84.0, 20.0),
egui::DragValue::new(&mut config.timeout_ms)
.range(50..=30_000)
.speed(50),
);
ui.colored_label(dim_text(), "毫秒");
});
ui.end_row();
});
});
}
fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
group_frame().show(ui, |ui| match config.mode {
SerialMode::SingleModule => {
ui.horizontal(|ui| {
ui.label("模块地址");
ui.add_sized(
egui::vec2(80.0, 20.0),
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 字节");
});
}
SerialMode::Manual => {
ui.horizontal(|ui| {
ui.label("发送");
ui.add_sized(
egui::vec2(300.0, 20.0),
egui::TextEdit::singleline(&mut config.manual_tx),
);
if ui.add(tag_button("发送")).clicked() {}
if ui.add(tag_button("清空")).clicked() {
config.manual_tx.clear();
}
});
}
SerialMode::Model => {
ui.horizontal(|ui| {
ui.label("模型");
ui.add_sized(
egui::vec2(300.0, 20.0),
egui::TextEdit::singleline(&mut config.model_path),
);
if ui.add(tag_button("加载")).clicked() {}
if ui.add(tag_button("运行")).clicked() {}
});
}
});
}
pub fn icon_button<'a>(
ui: &mut egui::Ui,
icon: IconButtonIcon<'a>,
tooltip: impl Into<egui::WidgetText>,
) -> egui::Response {
icon_button_sized(ui, icon, tooltip, egui::vec2(28.0, 24.0))
}
pub fn icon_button_sized<'a>(
ui: &mut egui::Ui,
icon: IconButtonIcon<'a>,
tooltip: impl Into<egui::WidgetText>,
size: egui::Vec2,
) -> egui::Response {
let button = match icon {
IconButtonIcon::Font(icon) => egui::Button::new(
egui::RichText::new(icon)
.color(egui::Color32::WHITE)
.size(size.y - 8.0),
),
IconButtonIcon::Png(source) => egui::Button::image(
egui::Image::new(source).fit_to_exact_size(egui::vec2(size.y - 8.0, size.y - 8.0)),
),
}
.fill(ENGINEERING_DARK.panel_strong)
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
.corner_radius(egui::CornerRadius::same(2))
.min_size(size);
ui.add(button).on_hover_text(tooltip)
}
fn mode_button(ui: &mut egui::Ui, mode: &mut SerialMode, value: SerialMode, label: &'static str) {
let selected = *mode == value;
let fill = if selected {
ENGINEERING_DARK.accent
} else {
ENGINEERING_DARK.panel_strong
};
let stroke = if selected {
ENGINEERING_DARK.accent_hot
} else {
ENGINEERING_DARK.border_soft
};
if ui
.add(
egui::Button::new(egui::RichText::new(label).color(egui::Color32::WHITE))
.fill(fill)
.stroke(egui::Stroke::new(1.0, stroke))
.min_size(egui::vec2(96.0, 24.0)),
)
.clicked()
{
*mode = value;
}
}
fn baud_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
egui::ComboBox::from_id_salt("serial_baud_rate")
.width(110.0)
.selected_text(config.baud_rate.to_string())
.show_ui(ui, |ui| {
for baud_rate in [
9_600, 19_200, 38_400, 57_600, 115_200, 230_400, 460_800, 921_600,
] {
ui.selectable_value(&mut config.baud_rate, baud_rate, baud_rate.to_string());
}
});
}
fn parity_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
egui::ComboBox::from_id_salt("serial_parity")
.width(110.0)
.selected_text(match config.parity {
Parity::None => "",
Parity::Odd => "",
Parity::Even => "",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut config.parity, Parity::None, "");
ui.selectable_value(&mut config.parity, Parity::Odd, "");
ui.selectable_value(&mut config.parity, Parity::Even, "");
});
}
pub fn draw_stats_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) {
draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| {
ui.horizontal(|ui| {
ui.colored_label(accent_text(), "0.030");
ui.label("81m:51s");
});
ui.separator();
group_frame().show(ui, |ui| {
ui.label("帧率 / GPU 信息");
ui.label("边界 589.0us");
ui.label("簇 12.8ms");
});
});
}
fn draw_floating_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
title: &'static str,
id: &'static str,
add_contents: impl FnOnce(&mut egui::Ui),
) {
if panel.visible {
let mut open = true;
let mut hide_requested = false;
let mut window_rect = None;
let window_response = egui::Window::new(title)
.id(egui::Id::new(id))
.open(&mut open)
.default_pos(panel.default_pos)
.title_bar(false)
.resizable(true)
.frame(panel_frame(ctx))
.show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.add(tag_button("隐藏")).clicked() {
hide_requested = true;
}
ui.add_space(6.0);
ui.colored_label(dim_text(), title);
});
ui.separator();
add_contents(ui);
});
if let Some(response) = window_response {
window_rect = Some(response.response.rect);
}
if hide_requested {
if let Some(rect) = window_rect {
let screen = ctx.content_rect();
let tag_size = egui::vec2(86.0, 22.0);
let distance_to_left = rect.left();
let distance_to_right = screen.right() - rect.right();
let x = if distance_to_left <= distance_to_right {
screen.left()
} else {
screen.right() - tag_size.x
};
let y = rect.top().clamp(screen.top(), screen.bottom() - tag_size.y);
panel.tag_pos = egui::pos2(x, y);
}
}
panel.visible = open && !hide_requested;
} else {
let response = egui::Area::new(egui::Id::new(format!("{id}_tag")))
.current_pos(panel.tag_pos)
.movable(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
ui.set_min_width(86.0);
if ui
.add(tag_button(format!("{title}")).min_size(egui::vec2(86.0, 22.0)))
.clicked()
{
panel.visible = true;
}
});
panel.tag_pos = response.response.rect.min;
}
}
fn draw_center_floating_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
id: &'static str,
top_offset: f32,
collapsed_label: &'static str,
add_contents: impl FnOnce(&mut egui::Ui),
) {
const PANEL_WIDTH: f32 = 430.0;
const SLIDE_DISTANCE: f32 = 18.0;
let anim = advance_center_panel_anim(ctx, panel);
let eased = ease_in_out(anim);
if !panel.visible || anim < 0.28 {
let handle_y = top_offset - (1.0 - eased) * 2.0;
egui::Area::new(egui::Id::new(format!("{id}_handle")))
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, handle_y))
.order(egui::Order::Tooltip)
.show(ctx, |ui| {
let handle_rect = ui
.allocate_exact_size(egui::vec2(112.0, 24.0), egui::Sense::hover())
.0;
if integrated_handle(ui, handle_rect, collapsed_label, false, "打开连接面板")
.clicked()
{
panel.visible = true;
}
});
}
if anim <= 0.02 {
return;
}
egui::Area::new(egui::Id::new(id))
.anchor(
egui::Align2::CENTER_TOP,
egui::vec2(0.0, top_offset - (1.0 - eased) * SLIDE_DISTANCE),
)
.order(egui::Order::Tooltip)
.show(ctx, |ui| {
let response = center_panel_shell(ui, PANEL_WIDTH, |ui| {
ui.set_width(PANEL_WIDTH);
add_contents(ui);
ui.add_space(6.0);
let handle_rect = allocate_center_panel_handle(ui);
if integrated_handle(ui, handle_rect, "", true, "收起连接面板").clicked() {
panel.visible = false;
}
handle_rect
});
paint_integrated_center_panel(ui, response.response.rect, response.inner, true);
});
}
fn advance_center_panel_anim(ctx: &egui::Context, panel: &mut FloatingPanelState) -> f32 {
let target = if panel.visible { 1.0 } else { 0.0 };
let now = ctx.input(|input| input.time);
let delta_time = panel
.center_anim_last_time
.map(|last_time| (now - last_time) as f32)
.unwrap_or(1.0 / 60.0)
.clamp(0.0, 0.05);
panel.center_anim_last_time = Some(now);
panel.center_anim_target = panel.visible;
let speed = 7.5;
let step = speed * delta_time;
if panel.center_anim < target {
panel.center_anim = (panel.center_anim + step).min(target);
} else if panel.center_anim > target {
panel.center_anim = (panel.center_anim - step).max(target);
}
if (panel.center_anim - target).abs() > 0.001 {
ctx.request_repaint();
}
panel.center_anim
}
fn ease_in_out(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
fn center_panel_shell<R>(
ui: &mut egui::Ui,
width: f32,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<R> {
egui::Frame::new()
.fill(ENGINEERING_DARK.panel)
.corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius))
.inner_margin(egui::Margin::symmetric(10, 8))
.show(ui, |ui| {
ui.set_width(width);
add_contents(ui)
})
}
fn allocate_center_panel_handle(ui: &mut egui::Ui) -> egui::Rect {
let handle_width = 112.0;
let handle_height = 24.0;
let left_space = ((ui.available_width() - handle_width) * 0.5).max(0.0);
let mut handle_rect = egui::Rect::NOTHING;
ui.horizontal(|ui| {
ui.add_space(left_space);
let (rect, _) = ui.allocate_exact_size(
egui::vec2(handle_width, handle_height),
egui::Sense::hover(),
);
handle_rect = rect;
});
handle_rect
}
fn integrated_handle(
ui: &mut egui::Ui,
rect: egui::Rect,
label: &str,
expanded: bool,
tooltip: &'static str,
) -> egui::Response {
let response = ui
.interact(
rect.expand2(egui::vec2(12.0, 5.0)),
ui.id().with(("center_panel_handle", expanded)),
egui::Sense::click(),
)
.on_hover_text(tooltip);
paint_integrated_handle(ui, rect, label, expanded, response.hovered());
response
}
fn paint_integrated_handle(
ui: &mut egui::Ui,
rect: egui::Rect,
label: &str,
expanded: bool,
hovered: bool,
) {
let center = rect.center();
if !expanded {
let fill = if hovered {
ENGINEERING_DARK.panel_strong
} else {
egui::Color32::from_rgb(31, 41, 52)
};
let points = vec![
egui::pos2(rect.left() - 12.0, rect.top() - 1.0),
egui::pos2(rect.right() + 12.0, rect.top() - 1.0),
egui::pos2(rect.right() + 3.0, rect.bottom() + 8.0),
egui::pos2(rect.left() - 3.0, rect.bottom() + 8.0),
];
ui.painter().add(egui::Shape::convex_polygon(
points.clone(),
fill,
egui::Stroke::new(1.0, ENGINEERING_DARK.border),
));
ui.painter().line_segment(
[points[2], points[3]],
egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft),
);
}
let arrow = if expanded { -1.0 } else { 1.0 };
let arrow_center_x = if label.is_empty() {
center.x
} else {
rect.right() - 18.0
};
let arrow_center = egui::pos2(arrow_center_x, center.y);
let arrow_color = if hovered {
ENGINEERING_DARK.accent_hot
} else {
ENGINEERING_DARK.text_dim
};
ui.painter().line_segment(
[
arrow_center + egui::vec2(-6.0, -2.0 * arrow),
arrow_center + egui::vec2(0.0, 4.0 * arrow),
],
egui::Stroke::new(1.7, arrow_color),
);
ui.painter().line_segment(
[
arrow_center + egui::vec2(6.0, -2.0 * arrow),
arrow_center + egui::vec2(0.0, 4.0 * arrow),
],
egui::Stroke::new(1.7, arrow_color),
);
if !label.is_empty() {
let galley = ui.fonts_mut(|fonts| {
fonts.layout_no_wrap(
label.to_owned(),
egui::FontId::proportional(12.0),
ENGINEERING_DARK.text,
)
});
ui.painter().galley(
egui::pos2(rect.left() + 14.0, center.y - galley.size().y * 0.5 - 1.0),
galley,
ENGINEERING_DARK.text,
);
}
}
fn paint_integrated_center_panel(
ui: &egui::Ui,
rect: egui::Rect,
handle_rect: egui::Rect,
handle_on_bottom: bool,
) {
let painter = ui.painter();
let stroke = egui::Stroke::new(1.0, ENGINEERING_DARK.border);
let accent_stroke = egui::Stroke::new(1.2, ENGINEERING_DARK.border_soft);
let top = rect.top();
let left = rect.left();
let right = rect.right();
let bottom = rect.bottom();
let radius = ENGINEERING_DARK.radius as f32;
let tab_left = handle_rect.left() - 18.0;
let tab_right = handle_rect.right() + 18.0;
let tab_recess = if handle_on_bottom {
handle_rect.top() - 2.0
} else {
handle_rect.bottom() + 2.0
};
if handle_on_bottom {
painter.line_segment(
[
egui::pos2(left + radius, top),
egui::pos2(right - radius, top),
],
stroke,
);
} else {
painter.line_segment(
[egui::pos2(left + radius, top), egui::pos2(tab_left, top)],
stroke,
);
painter.line_segment(
[egui::pos2(tab_right, top), egui::pos2(right - radius, top)],
stroke,
);
}
painter.line_segment(
[
egui::pos2(left, top + radius),
egui::pos2(left, bottom - radius),
],
stroke,
);
painter.line_segment(
[
egui::pos2(right, top + radius),
egui::pos2(right, bottom - radius),
],
stroke,
);
if handle_on_bottom {
painter.line_segment(
[
egui::pos2(left + radius, bottom),
egui::pos2(tab_left, bottom),
],
stroke,
);
painter.line_segment(
[
egui::pos2(tab_right, bottom),
egui::pos2(right - radius, bottom),
],
stroke,
);
} else {
painter.line_segment(
[
egui::pos2(left + radius, bottom),
egui::pos2(right - radius, bottom),
],
stroke,
);
}
let tab_points = if handle_on_bottom {
vec![
egui::pos2(tab_left, bottom),
egui::pos2(handle_rect.left() - 8.0, tab_recess),
egui::pos2(handle_rect.right() + 8.0, tab_recess),
egui::pos2(tab_right, bottom),
]
} else {
vec![
egui::pos2(tab_left, top),
egui::pos2(handle_rect.left() - 8.0, tab_recess),
egui::pos2(handle_rect.right() + 8.0, tab_recess),
egui::pos2(tab_right, top),
]
};
painter.add(egui::Shape::line(tab_points, accent_stroke));
}

11
src/utils.rs Normal file
View File

@@ -0,0 +1,11 @@
use anyhow;
use serialport::available_ports;
pub fn serial_enum() -> anyhow::Result<Vec<String>> {
let ports = available_ports()
.map_err(|e| anyhow::anyhow!("available_ports failed: {}", e))?
.into_iter()
.map(|info| info.port_name)
.collect();
Ok(ports)
}

BIN
static/Hack-Bold.ttf Normal file

Binary file not shown.

BIN
static/cpu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB