9 Commits

Author SHA1 Message Date
lenn
d93a6694cf fix: correct 3D fingertip sampling and PCB mapping 2026-07-27 17:25:30 +08:00
lenn
0d1296c482 更新ui中 2026-07-21 17:36:46 +08:00
lenn
0a9fea0f0a Improve panel layout and per-user installer 2026-07-02 13:38:00 +08:00
lenn
da77f2f194 Improve hand mode panels and recording 2026-07-01 17:08:07 +08:00
lenn
1ed729f8da Prepare Windows installer release 2026-06-30 17:43:02 +08:00
lenn
444c20f233 Add animated force panels 2026-06-30 11:10:29 +08:00
lenn
d4f160af75 Integrate hand gateway and spatial force rendering 2026-06-29 18:55:42 +08:00
lennlouisgeek
f30ebcf20b Add pressure visual modes and breakout game 2026-06-29 03:13:57 +08:00
lennlouisgeek
eab24fc2bf update 2026-06-28 23:29:13 +08:00
32 changed files with 5267 additions and 559 deletions

View File

@@ -1,35 +0,0 @@
{
"hooks": {
"pre-exec": [
{
"matcher": "",
"command": "scale gate pre-tool Bash --args-json \"$ARGS\" --session-id \"$SESSION_ID\""
},
{
"matcher": "edit|write",
"command": "scale gate pre-tool Edit --args-json \"$ARGS\" --session-id \"$SESSION_ID\""
}
],
"post-exec": [
{
"matcher": "edit|write",
"command": "scale gate post-tool Edit --args-json \"$ARGS\" --exit-code \"$EXIT_CODE\" --session-id \"$SESSION_ID\""
},
{
"matcher": "",
"command": "scale gate post-tool Bash --args-json \"$ARGS\" --exit-code \"$EXIT_CODE\" --session-id \"$SESSION_ID\""
}
],
"before-stop": [
{
"matcher": "",
"command": "scale gate before-stop --session-id \"$SESSION_ID\""
}
]
},
"permissions": {
"allow": [
"scale:*"
]
}
}

2
Cargo.lock generated
View File

@@ -1304,7 +1304,7 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "eskin-model-player"
version = "0.5.0"
version = "5.0.0"
dependencies = [
"anyhow",
"bytemuck",

View File

@@ -1,9 +1,14 @@
[package]
name = "eskin-model-player"
version = "0.5.0"
version = "5.0.0"
edition = "2024"
authors = ["JOYSONQUIN"]
description = "Desktop pressure sensor visualization and playback application."
build = "build.rs"
[package.metadata.wix]
eula = false
[dependencies]
eframe = { version = "0.34.2", features = ["default", "wgpu", "__screenshot"] }
env_logger = { version = "0.11.10", features = ["auto-color", "humantime"] }
@@ -22,3 +27,8 @@ gltf = "1.4.1"
[build-dependencies]
anyhow = "1.0.102"
fs_extra = "1.3.0"
[[bin]]
name = "ESkinPlayer"
path = "src/main.rs"

1060
docs/cargo-wix-guide.md Normal file

File diff suppressed because it is too large Load Diff

BIN
res/hand.glb Normal file

Binary file not shown.

View File

@@ -1,30 +1,35 @@
use crate::breakout::{BreakoutGame, control_from_matrix};
use crate::connection::ConnectionManager;
use crate::force::{ForceEstimatorState, HudSpatialForce};
use crate::recording::Recorder;
use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout};
use crate::style::{self, ONE_DARK_PRO, apply_fonts, apply_theme, dim_text, layout};
use crate::ui::SerialMode;
use crate::{
matrix::{MATRIX_COLS, MATRIX_ROWS},
matrix::{MATRIX_COLS, MATRIX_ROWS, UNFOLDED_SENSOR_SEGMENT_COUNTS},
render::{
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback,
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, PressureSamples,
WgpuBackgroundCallback,
},
ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
draw_config_panel, draw_export_panel, draw_matrix_config_panel, draw_stats_panel,
draw_config_panel, draw_export_panel, draw_hand_force_panels, draw_stats_panel,
panel_restore_item,
},
};
use eframe::{egui, egui_wgpu};
use std::sync::Arc;
const DATA_LOG_EVERY_FRAMES: u64 = 30;
const SUMMARY_POINTS_PER_SERIES: usize = 42;
const HAND_FORCE_PANEL_COUNT: usize = UNFOLDED_SENSOR_SEGMENT_COUNTS.len();
const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = UNFOLDED_SENSOR_SEGMENT_COUNTS;
pub struct EskinDesktopApp {
connect_panel: FloatingPanelState,
connect_state: ConnectPanelState,
connection: Arc<ConnectionManager>,
pressure_matrix: PressureFrame,
data_log_frame: u64,
hand_pressure: PressureSamples,
scene_panel: FloatingPanelState,
config_panel: FloatingPanelState,
config_state: ConfigPanelState,
@@ -36,6 +41,16 @@ pub struct EskinDesktopApp {
matrix_config_panel: FloatingPanelState,
matrix_config: MatrixConfigState,
signal_history: Vec<f32>,
hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT],
force_estimator: ForceEstimatorState,
latest_spatial_force: Option<HudSpatialForce>,
latest_raw_matrix: Vec<u32>,
latest_matrix_rows: u32,
latest_matrix_cols: u32,
breakout_visible: bool,
breakout_game: BreakoutGame,
context_menu_open: bool,
context_menu_pos: egui::Pos2,
active_mode: ActiveMode,
}
@@ -66,7 +81,7 @@ impl EskinDesktopApp {
connect_state: ConnectPanelState::default(),
connection: Arc::new(ConnectionManager::new()),
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
data_log_frame: 0,
hand_pressure: Vec::new(),
scene_panel: FloatingPanelState::new(
[layout::LEFT_X, layout::TOP_Y],
[layout::LEFT_TAG_X, layout::TOP_Y],
@@ -92,6 +107,16 @@ impl EskinDesktopApp {
),
matrix_config: MatrixConfigState::default(),
signal_history: Vec::with_capacity(128),
hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)),
force_estimator: ForceEstimatorState::new(),
latest_spatial_force: None,
latest_raw_matrix: Vec::new(),
latest_matrix_rows: MATRIX_ROWS,
latest_matrix_cols: MATRIX_COLS,
breakout_visible: false,
breakout_game: BreakoutGame::default(),
context_menu_open: false,
context_menu_pos: egui::pos2(24.0, 48.0),
active_mode: ActiveMode::Finger(FingerMode {
rows: 12,
cols: 7,
@@ -101,10 +126,7 @@ impl EskinDesktopApp {
}
}
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
self.update_pressure_matrix();
let rect = ui.max_rect();
fn draw_wgpu_background_rect(&self, ui: &mut egui::Ui, rect: egui::Rect) {
let width = rect.width().max(1.0);
let height = rect.height().max(1.0);
@@ -114,34 +136,120 @@ impl EskinDesktopApp {
width,
height,
pressure: self.pressure_matrix,
hand_pressure: self.hand_pressure.clone(),
active_mode: self.active_mode.clone(),
},
));
self.paint_spatial_force_overlay(ui, rect);
}
fn paint_spatial_force_overlay(&self, ui: &egui::Ui, rect: egui::Rect) {
let Some(force) = self.latest_spatial_force else {
return;
};
let painter = ui.painter();
let center = rect.center();
let magnitude = force.magnitude.clamp(0.0, 1.8);
let length = 34.0 + magnitude * 54.0;
let angle = force.angle_deg.to_radians();
let direction = egui::vec2(angle.cos(), angle.sin());
let end = center + direction * length;
let side = egui::vec2(-direction.y, direction.x);
let color = egui::Color32::from_rgb(255, 196, 54);
let glow = egui::Color32::from_rgba_unmultiplied(255, 196, 54, 58);
painter.line_segment([center, end], egui::Stroke::new(9.0_f32, glow));
painter.line_segment([center, end], egui::Stroke::new(2.4_f32, color));
painter.line_segment(
[end, end - direction * 14.0 + side * 7.0],
egui::Stroke::new(2.4_f32, color),
);
painter.line_segment(
[end, end - direction * 14.0 - side * 7.0],
egui::Stroke::new(2.4_f32, color),
);
painter.circle_filled(center, 4.2, color);
}
fn draw_workspace(&mut self, ui: &mut egui::Ui) {
if self.breakout_visible {
self.draw_split_workspace(ui);
} else {
self.draw_wgpu_background_rect(ui, ui.max_rect());
}
}
fn draw_split_workspace(&mut self, ui: &mut egui::Ui) {
let screen = ui.max_rect();
let content = egui::Rect::from_min_max(
screen.left_top() + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT),
screen.right_bottom(),
);
let gutter = 10.0;
let half_width = (content.width() - gutter).max(1.0) * 0.5;
let left = egui::Rect::from_min_size(content.min, egui::vec2(half_width, content.height()));
let right = egui::Rect::from_min_max(
egui::pos2(left.right() + gutter, content.top()),
content.right_bottom(),
);
ui.painter()
.rect_filled(screen, egui::CornerRadius::ZERO, ONE_DARK_PRO.bg);
paint_split_viewport_shell(ui, left, "DOTMATRIX", "压力矩阵 / WGPU");
paint_split_viewport_shell(ui, right, "BREAKOUT", "压力控制打砖块");
let left_body = split_viewport_body(left);
let right_body = split_viewport_body(right);
self.draw_wgpu_background_rect(ui, left_body);
let breakout_control = control_from_matrix(
&self.latest_raw_matrix,
self.latest_matrix_rows,
self.latest_matrix_cols,
);
self.breakout_game.draw_viewport(
ui.ctx(),
right_body,
&mut self.breakout_visible,
breakout_control,
);
}
fn update_pressure_matrix(&mut self) {
if let Some(sample) = self.connection.take_latest_sample() {
if self.config_state.mode == SerialMode::Finger3D {
eprintln!(
"[3d-rawdata] rows={} cols={} values={:?}",
sample.rows, sample.cols, sample.matrix
);
}
normalize_pressure_sample(
&sample.matrix,
sample.rows,
sample.cols,
&mut self.pressure_matrix,
);
self.hand_pressure = normalize_pressure_samples(&sample.matrix);
self.latest_raw_matrix.clear();
self.latest_raw_matrix.extend_from_slice(&sample.matrix);
self.latest_matrix_rows = sample.rows;
self.latest_matrix_cols = sample.cols;
// Feed data to recorder
self.recorder.add_frame(&sample.matrix);
self.latest_spatial_force = self.force_estimator.analyze(&sample.matrix);
// Update signal history (sum of all pressure values)
let total: f32 = sample.matrix.iter().map(|v| *v as f32).sum();
self.signal_history.push(total);
if self.signal_history.len() > 128 {
// Keep JE-Skin's summary path separate from the optional spatial-force vector.
let raw_total = sample
.matrix
.iter()
.fold(0_u64, |sum, value| sum + *value as u64)
.min(u32::MAX as u64) as u32;
let force = raw_to_g1(raw_total).min(25.6);
let force = if force <= 0.1 { 0.0 } else { force };
self.signal_history.push(force);
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
self.signal_history.remove(0);
}
self.data_log_frame += 1;
if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 {
log_pressure_sample(&sample.matrix, sample.rows, sample.cols);
}
}
}
@@ -159,7 +267,7 @@ impl EskinDesktopApp {
);
ui.painter().line_segment(
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
egui::Stroke::new(1.0, ONE_DARK_PRO.border),
egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border),
);
// Drag-to-move: double-click to maximize, drag to move
@@ -200,14 +308,14 @@ impl EskinDesktopApp {
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)),
egui::Stroke::new(1.5_f32, 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)),
egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(80, 0, 0)),
);
}
if close_resp.clicked() {
@@ -226,7 +334,7 @@ impl EskinDesktopApp {
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)),
egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(120, 80, 0)),
);
}
if min_resp.clicked() {
@@ -245,7 +353,7 @@ impl EskinDesktopApp {
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::Stroke::new(1.5_f32, egui::Color32::from_rgb(0, 80, 10)),
egui::StrokeKind::Outside,
);
}
@@ -256,57 +364,171 @@ impl EskinDesktopApp {
}
}
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
fn draw_floating_panels(
&mut self,
ctx: &egui::Context,
stats: crate::serial_core::serial::SerialIoStats,
) {
// draw_scene_panel(ctx, &mut self.scene_panel);
if let Some(next_mode) =
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state)
{
// if self.config_state.mode == SerialMode::Hand && self.stats_panel.visible {
// self.config_panel.visible = false;
// self.export_panel.visible = false;
// self.matrix_config_panel.visible = false;
// }
if let Some(next_mode) = draw_config_panel(
ctx,
&mut self.config_panel,
&mut self.config_state,
&self.connection,
&self.recorder,
stats,
100.0,
100.0,
) {
self.switch_mode(next_mode);
}
draw_stats_panel(ctx, &mut self.stats_panel);
match self.config_state.mode {
SerialMode::Finger => {
draw_stats_panel(
ctx,
&mut self.stats_panel,
&self.signal_history,
self.latest_spatial_force,
);
}
SerialMode::Finger3D => {
draw_stats_panel(
ctx,
&mut self.stats_panel,
&self.signal_history,
self.latest_spatial_force,
);
}
SerialMode::Hand => {
draw_hand_force_panels(ctx, self.stats_panel.visible, &self.hand_signal_histories);
}
}
draw_export_panel(
ctx,
&mut self.export_panel,
&self.recorder,
&mut self.export_path,
);
draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
// draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
}
fn draw_panel_context_menu(&mut self, ui: &mut egui::Ui) {
let response = ui.interact(
ui.max_rect(),
egui::Id::new("workspace_panel_context_menu"),
egui::Sense::click(),
);
response.context_menu(|ui| {
ui.label("显示面板");
ui.separator();
panel_restore_item(ui, "配置", &mut self.config_panel);
panel_restore_item(ui, "录制", &mut self.export_panel);
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
panel_restore_item(ui, "统计", &mut self.stats_panel);
ui.separator();
if ui.button("全部显示").clicked() {
self.config_panel.visible = true;
self.export_panel.visible = true;
self.matrix_config_panel.visible = true;
self.stats_panel.visible = true;
ui.close();
}
fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
let (secondary_clicked, pointer_pos, escape_pressed) = ctx.input(|input| {
(
input.pointer.secondary_clicked(),
input.pointer.interact_pos(),
input.key_pressed(egui::Key::Escape),
)
});
if secondary_clicked {
self.context_menu_open = true;
self.context_menu_pos = pointer_pos.unwrap_or(self.context_menu_pos);
}
if escape_pressed {
self.context_menu_open = false;
}
if !self.context_menu_open {
return;
}
let mut close_menu = false;
let response = egui::Area::new(egui::Id::new("workspace_context_menu_area"))
.fixed_pos(self.context_menu_pos)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(132.0);
ui.label("显示面板");
ui.separator();
panel_restore_item(ui, "配置", &mut self.config_panel);
panel_restore_item(ui, "录制", &mut self.export_panel);
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
if self.stats_panel.visible {
if ui
.add_sized(
egui::vec2(ui.available_width(), 0.0),
egui::Button::new("隐藏 统计"),
)
.clicked()
{
self.stats_panel.visible = false;
close_menu = true;
}
} else {
panel_restore_item(ui, "统计", &mut self.stats_panel);
}
if ui
.add_sized(
egui::vec2(ui.available_width(), 0.0),
egui::Button::new("打砖块"),
)
.clicked()
{
self.breakout_visible = true;
close_menu = true;
}
ui.separator();
if ui
.add_sized(
egui::vec2(ui.available_width(), 0.0),
egui::Button::new("全部显示"),
)
.clicked()
{
self.config_panel.visible = true;
self.export_panel.visible = true;
self.matrix_config_panel.visible = true;
self.stats_panel.visible = true;
self.breakout_visible = true;
close_menu = true;
}
});
});
let clicked_outside = ctx.input(|input| {
input.pointer.primary_clicked()
&& input
.pointer
.interact_pos()
.is_some_and(|pos| !response.response.rect.contains(pos))
});
if close_menu || clicked_outside {
self.context_menu_open = false;
}
}
fn switch_mode(&mut self, next: SerialMode) {
if next == SerialMode::Finger3D {
// The 3D fingertip PCB sends one 12x9 TactileA frame (108 cells).
self.matrix_config = MatrixConfigState {
rows: 12,
cols: 9,
color_min: 0.0,
color_max: 7000.0,
};
}
self.connect_state.mode = next;
self.config_state.mode = next;
self.config_state.baud_rate = next.baud_rate();
self.connection.disconnect();
self.pressure_matrix.fill([0.0, 0.0]);
self.data_log_frame = 0;
self.hand_pressure.clear();
self.force_estimator.reset();
self.latest_spatial_force = None;
self.signal_history.clear();
self.hand_signal_histories
.iter_mut()
.for_each(|history| history.clear());
self.active_mode = match next {
SerialMode::Finger => ActiveMode::Finger(FingerMode {
@@ -315,32 +537,121 @@ impl EskinDesktopApp {
range: 0..7000,
dot: true,
}),
SerialMode::Hand => ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
}
SerialMode::Finger3D | SerialMode::Hand => {
ActiveMode::Hand(HandGatewayMode { range: 0..7000 })
}
};
}
}
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(", ");
fn update_hand_signal_histories(
histories: &mut [Vec<f32>; HAND_FORCE_PANEL_COUNT],
raw_values: &[u32],
) {
let mut offset = 0usize;
for (history, sample_count) in histories.iter_mut().zip(HAND_FORCE_SEGMENT_COUNTS) {
let raw_total = raw_values
.get(offset..offset + sample_count)
.unwrap_or(&[])
.iter()
.fold(0_u64, |sum, value| sum + *value as u64)
.min(u32::MAX as u64) as u32;
let force = raw_to_g1(raw_total).min(25.6);
let force = if force <= 0.1 { 0.0 } else { force };
println!(
"[pressure] {rows}x{cols} cells={} non_zero={non_zero} max={max} sum={sum} first=[{preview}]",
raw.len()
history.push(force);
if history.len() > SUMMARY_POINTS_PER_SERIES {
history.remove(0);
}
offset += sample_count;
}
}
fn raw_to_g1(raw: u32) -> f32 {
const RAW: [u32; 12] = [
0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444,
];
const FORCE_CENTI_N: [f32; 12] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0, 2557.0,
];
if raw <= RAW[0] {
return FORCE_CENTI_N[0] / 100.0;
}
if raw >= RAW[RAW.len() - 1] {
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
}
let mut left = 0usize;
let mut right = RAW.len() - 1;
while left + 1 < right {
let mid = (left + right) / 2;
if RAW[mid] <= raw {
left = mid;
} else {
right = mid;
}
}
let raw_left = RAW[left] as f32;
let raw_right = RAW[right] as f32;
let span = (raw_right - raw_left).max(1.0);
let ratio = (raw as f32 - raw_left) / span;
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
}
fn paint_split_viewport_shell(
ui: &egui::Ui,
rect: egui::Rect,
title: &'static str,
subtitle: &'static str,
) {
let painter = ui.painter();
painter.rect_filled(
rect,
egui::CornerRadius::same(8),
egui::Color32::from_rgb(11, 17, 23),
);
painter.rect_stroke(
rect,
egui::CornerRadius::same(8),
egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border_soft),
egui::StrokeKind::Outside,
);
painter.line_segment(
[
rect.left_top() + egui::vec2(12.0, 34.0),
rect.right_top() + egui::vec2(-12.0, 34.0),
],
egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border_soft),
);
painter.text(
rect.left_top() + egui::vec2(14.0, 16.0),
egui::Align2::LEFT_CENTER,
title,
egui::FontId::monospace(13.0),
ONE_DARK_PRO.accent,
);
painter.text(
rect.left_top() + egui::vec2(122.0, 16.0),
egui::Align2::LEFT_CENTER,
subtitle,
egui::FontId::proportional(12.0),
ONE_DARK_PRO.text_dim,
);
}
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;
fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
egui::Rect::from_min_max(
rect.left_top() + egui::vec2(10.0, 44.0),
rect.right_bottom() - egui::vec2(10.0, 10.0),
)
}
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
normalized.fill([0.0, 0.0]);
let src_cols = cols.max(1);
@@ -352,27 +663,70 @@ fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut
let src_index = (row * src_cols + col) as usize;
let dst_index = (row * MATRIX_COLS + col) as usize;
if let Some(value) = raw.get(src_index) {
let raw_value = *value as f32;
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0);
let display_value = if raw_value <= RANGE_MIN + 4.0 {
0.0
} else {
raw_value.round().min(9999.0)
};
normalized[dst_index] = [mapped, display_value];
normalized[dst_index] = normalize_pressure_value(*value);
}
}
}
}
fn normalize_pressure_samples(raw: &[u32]) -> PressureSamples {
raw.iter().copied().map(normalize_pressure_value).collect()
}
fn normalize_pressure_value(value: u32) -> [f32; 2] {
const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;
let raw_value = value as f32;
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0);
let display_value = if raw_value <= RANGE_MIN + 4.0 {
0.0
} else {
raw_value.round().min(9999.0)
};
[mapped, display_value]
}
fn draw_config_bar_mode(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<SerialMode> {
let mut changed_to = None;
ui.horizontal(|ui| {
ui.colored_label(dim_text(), "单面指尖");
if mode_button(ui, &mut config.mode, SerialMode::Finger, "单面指尖") {
changed_to = Some(SerialMode::Finger);
}
});
changed_to
}
fn mode_button(
ui: &mut egui::Ui,
mode: &mut SerialMode,
value: SerialMode,
label: &'static str,
) -> bool {
let clicked = ui.add(style::mode_button(label, *mode == value)).clicked();
if clicked && *mode != value {
*mode = value;
true
} else {
false
}
}
impl eframe::App for EskinDesktopApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
let stats = self.connection.stats();
self.draw_wgpu_background(ui);
self.draw_panel_context_menu(ui);
self.update_pressure_matrix();
self.draw_workspace(ui);
self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx);
self.draw_floating_panels(&ctx, stats);
self.draw_panel_context_menu(&ctx);
// Keep repainting while the wgpu background is a realtime viewport.
ctx.request_repaint();

647
src/breakout.rs Normal file
View File

@@ -0,0 +1,647 @@
use eframe::egui;
use crate::style::{self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, ONE_DARK_PRO};
const BRICK_ROWS: usize = 7;
const BRICK_COLS: usize = 12;
const PADDLE_W: f32 = 0.24;
const PADDLE_H: f32 = 0.030;
const PADDLE_Y: f32 = 0.88;
const PADDLE_SPEED: f32 = 0.92;
const BALL_RADIUS: f32 = 0.010;
const BALL_SPEED: f32 = 0.52;
const MAX_BALL_SPEED: f32 = 0.86;
const PRESSURE_RANGE_MIN: f32 = 0.0;
const PRESSURE_RANGE_MAX: f32 = 7000.0;
const PAUSE_COOLDOWN: f64 = 0.64;
#[derive(Clone, Copy, PartialEq, Eq)]
enum BreakoutPhase {
Idle,
Running,
Paused,
Over,
}
struct Brick {
rect: egui::Rect,
alive: bool,
flash: f32,
}
#[derive(Default, Clone, Copy)]
pub struct BreakoutControl {
pub axis: f32,
pub top_force: f32,
pub left_force: f32,
pub right_force: f32,
}
pub struct BreakoutGame {
phase: BreakoutPhase,
bricks: Vec<Brick>,
paddle_x: f32,
ball_pos: egui::Pos2,
ball_vel: egui::Vec2,
score: u32,
combo: u32,
lives: u32,
level: u32,
last_time: Option<f64>,
previous_pause_gesture: bool,
pause_locked_until: f64,
}
impl Default for BreakoutGame {
fn default() -> Self {
let mut game = Self {
phase: BreakoutPhase::Idle,
bricks: Vec::new(),
paddle_x: 0.5,
ball_pos: egui::pos2(0.5, PADDLE_Y - PADDLE_H - BALL_RADIUS),
ball_vel: egui::vec2(0.0, 0.0),
score: 0,
combo: 0,
lives: 3,
level: 1,
last_time: None,
previous_pause_gesture: false,
pause_locked_until: 0.0,
};
game.rebuild_bricks();
game
}
}
impl BreakoutGame {
pub fn draw_viewport(
&mut self,
ctx: &egui::Context,
rect: egui::Rect,
visible: &mut bool,
control: BreakoutControl,
) {
if !*visible {
self.last_time = None;
return;
}
ctx.request_repaint();
let now = ctx.input(|input| input.time);
let dt = self
.last_time
.map(|last| (now - last) as f32)
.unwrap_or(1.0 / 60.0)
.clamp(0.0, 1.0 / 30.0);
self.last_time = Some(now);
let mut close_requested = false;
egui::Area::new(egui::Id::new("breakout_game_viewport"))
.fixed_pos(rect.min)
.order(egui::Order::Middle)
.show(ctx, |ui| {
ui.set_min_size(rect.size());
ui.set_max_size(rect.size());
style::group_frame().show(ui, |ui| {
ui.set_min_size(rect.size() - egui::vec2(20.0, 16.0));
if self.draw_contents(ui, control, dt, now, true) {
close_requested = true;
}
});
});
if close_requested {
*visible = false;
}
}
fn draw_contents(
&mut self,
ui: &mut egui::Ui,
control: BreakoutControl,
dt: f32,
now: f64,
embedded: bool,
) -> bool {
let mut close_requested = false;
ui.horizontal(|ui| {
ui.colored_label(
ACCENT_BLUE,
egui::RichText::new("NEON BREAKOUT").monospace().size(13.0),
);
ui.label(style::subtle_text("压力矩阵控制 / 左右分区驱动"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if embedded && ui.add(style::tag_button("退出分屏")).clicked() {
close_requested = true;
}
if ui.add(style::tag_button("重开")).clicked() {
self.reset();
self.start();
}
if ui.add(style::tag_button("暂停")).clicked() {
self.toggle_pause();
}
});
});
ui.add_space(5.0);
ui.horizontal(|ui| {
status_chip(ui, "状态", self.status_text(), status_color(self.phase));
status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN);
status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE);
status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED);
status_chip(ui, "关卡", self.level.to_string(), ACCENT_BLUE);
});
ui.add_space(7.0);
let available = ui.available_size();
let game_size = egui::vec2(available.x.max(500.0), (available.y - 6.0).max(330.0));
let (rect, response) = ui.allocate_exact_size(game_size, egui::Sense::click());
if response.clicked() && self.phase != BreakoutPhase::Running {
self.start();
}
let keyboard_axis = ui.input(|input| {
(input.key_down(egui::Key::ArrowRight) as i32
- input.key_down(egui::Key::ArrowLeft) as i32) as f32
});
if ui.input(|input| input.key_pressed(egui::Key::Space)) {
self.start();
}
if ui.input(|input| input.key_pressed(egui::Key::P)) {
self.toggle_pause();
}
let axis = if keyboard_axis.abs() > 0.0 {
keyboard_axis
} else {
control.axis
};
self.handle_pressure_gesture(control.top_force, now);
self.update(dt, axis);
self.paint(ui, rect, control);
close_requested
}
fn reset(&mut self) {
self.phase = BreakoutPhase::Idle;
self.paddle_x = 0.5;
self.ball_pos = egui::pos2(0.5, PADDLE_Y - PADDLE_H - BALL_RADIUS);
self.ball_vel = egui::Vec2::ZERO;
self.score = 0;
self.combo = 0;
self.lives = 3;
self.level = 1;
self.rebuild_bricks();
}
fn start(&mut self) {
if self.phase == BreakoutPhase::Over {
self.reset();
}
if self.phase != BreakoutPhase::Running {
self.phase = BreakoutPhase::Running;
if self.ball_vel == egui::Vec2::ZERO {
self.launch_ball();
}
}
}
fn toggle_pause(&mut self) {
self.phase = match self.phase {
BreakoutPhase::Running => BreakoutPhase::Paused,
BreakoutPhase::Paused => BreakoutPhase::Running,
other => other,
};
}
fn handle_pressure_gesture(&mut self, top_force: f32, now: f64) {
let threshold = pressure_pause_threshold();
let active = top_force >= threshold;
if active && !self.previous_pause_gesture && now >= self.pause_locked_until {
if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Over {
self.start();
} else {
self.toggle_pause();
}
self.pause_locked_until = now + PAUSE_COOLDOWN;
}
self.previous_pause_gesture = active;
}
fn update(&mut self, dt: f32, axis: f32) {
for brick in &mut self.bricks {
brick.flash = (brick.flash - dt * 2.4).max(0.0);
}
if self.phase != BreakoutPhase::Running {
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
return;
}
self.paddle_x = (self.paddle_x + axis.clamp(-1.0, 1.0) * PADDLE_SPEED * dt)
.clamp(PADDLE_W * 0.5, 1.0 - PADDLE_W * 0.5);
self.ball_pos += self.ball_vel * dt;
self.resolve_wall_collision();
self.resolve_paddle_collision();
self.resolve_brick_collision();
}
fn launch_ball(&mut self) {
let direction = if self.level.is_multiple_of(2) {
-0.24
} else {
0.24
};
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED;
}
fn resolve_wall_collision(&mut self) {
if self.ball_pos.x - BALL_RADIUS <= 0.0 {
self.ball_pos.x = BALL_RADIUS;
self.ball_vel.x = self.ball_vel.x.abs();
} else if self.ball_pos.x + BALL_RADIUS >= 1.0 {
self.ball_pos.x = 1.0 - BALL_RADIUS;
self.ball_vel.x = -self.ball_vel.x.abs();
}
if self.ball_pos.y - BALL_RADIUS <= 0.0 {
self.ball_pos.y = BALL_RADIUS;
self.ball_vel.y = self.ball_vel.y.abs();
}
if self.ball_pos.y - BALL_RADIUS > 1.0 {
self.lives = self.lives.saturating_sub(1);
self.combo = 0;
if self.lives == 0 {
self.phase = BreakoutPhase::Over;
self.ball_vel = egui::Vec2::ZERO;
} else {
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
self.launch_ball();
}
}
}
fn resolve_paddle_collision(&mut self) {
if self.ball_vel.y <= 0.0 {
return;
}
let paddle = paddle_rect(self.paddle_x);
if !circle_hits_rect(self.ball_pos, BALL_RADIUS, paddle) {
return;
}
self.ball_pos.y = paddle.top() - BALL_RADIUS;
let offset = ((self.ball_pos.x - self.paddle_x) / (PADDLE_W * 0.5)).clamp(-1.0, 1.0);
let speed = self.ball_vel.length().clamp(BALL_SPEED, MAX_BALL_SPEED);
self.ball_vel = egui::vec2(offset * 0.82, -1.0).normalized() * speed;
self.combo = 0;
}
fn resolve_brick_collision(&mut self) {
for brick in &mut self.bricks {
if !brick.alive || !circle_hits_rect(self.ball_pos, BALL_RADIUS, brick.rect) {
continue;
}
let overlap_x = (self.ball_pos.x + BALL_RADIUS - brick.rect.left())
.min(brick.rect.right() - (self.ball_pos.x - BALL_RADIUS));
let overlap_y = (self.ball_pos.y + BALL_RADIUS - brick.rect.top())
.min(brick.rect.bottom() - (self.ball_pos.y - BALL_RADIUS));
if overlap_x < overlap_y {
self.ball_vel.x *= -1.0;
} else {
self.ball_vel.y *= -1.0;
}
brick.alive = false;
brick.flash = 1.0;
self.combo += 1;
self.score += 40 + self.combo * 5;
let speed = (self.ball_vel.length() * 1.014).min(MAX_BALL_SPEED);
self.ball_vel = self.ball_vel.normalized() * speed;
break;
}
if self.bricks.iter().all(|brick| !brick.alive) {
self.level += 1;
self.rebuild_bricks();
self.launch_ball();
}
}
fn rebuild_bricks(&mut self) {
self.bricks.clear();
let gap_x = 0.012;
let gap_y = 0.017;
let left = 0.075;
let top = 0.10;
let total_w = 0.85;
let brick_w = (total_w - gap_x * (BRICK_COLS - 1) as f32) / BRICK_COLS as f32;
let brick_h = 0.040;
for row in 0..BRICK_ROWS {
for col in 0..BRICK_COLS {
let x = left + col as f32 * (brick_w + gap_x);
let y = top + row as f32 * (brick_h + gap_y);
self.bricks.push(Brick {
rect: egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(brick_w, brick_h)),
alive: true,
flash: 0.0,
});
}
}
}
fn status_text(&self) -> &'static str {
match self.phase {
BreakoutPhase::Idle => "待机",
BreakoutPhase::Running => "运行",
BreakoutPhase::Paused => "暂停",
BreakoutPhase::Over => "结束",
}
}
fn paint(&self, ui: &egui::Ui, rect: egui::Rect, control: BreakoutControl) {
let painter = ui.painter_at(rect);
painter.rect_filled(
rect,
egui::CornerRadius::same(6),
egui::Color32::from_rgb(8, 14, 19),
);
painter.rect_stroke(
rect,
egui::CornerRadius::same(6),
egui::Stroke::new(1.0_f32, color_alpha(ONE_DARK_PRO.accent, 110)),
egui::StrokeKind::Outside,
);
paint_arena_grid(&painter, rect);
for (index, brick) in self.bricks.iter().enumerate() {
paint_brick(&painter, rect, brick, index);
}
paint_paddle(&painter, rect, self.paddle_x);
paint_ball(&painter, rect, self.ball_pos);
paint_control_meter(&painter, rect, control);
if self.phase == BreakoutPhase::Idle
|| self.phase == BreakoutPhase::Paused
|| self.phase == BreakoutPhase::Over
{
paint_center_overlay(&painter, rect, self.phase);
}
}
}
pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl {
if raw.is_empty() {
return BreakoutControl::default();
}
let rows = rows.max(1) as usize;
let cols = cols.max(1) as usize;
let sample_rows = rows.min(2);
let sample_cols = cols.min(2);
let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 {
let mut sum = 0.0;
let mut count = 0.0;
for row in row_start..row_end {
for col in col_start..col_end {
let index = row * cols + col;
if let Some(value) = raw.get(index) {
sum += *value as f32;
count += 1.0;
}
}
}
if count > 0.0 { sum / count } else { 0.0 }
};
let tl = avg(0, sample_rows, 0, sample_cols);
let tr = avg(0, sample_rows, cols.saturating_sub(sample_cols), cols);
let bl = avg(rows.saturating_sub(sample_rows), rows, 0, sample_cols);
let br = avg(
rows.saturating_sub(sample_rows),
rows,
cols.saturating_sub(sample_cols),
cols,
);
let left_force = tl + bl;
let right_force = tr + br;
let top_force = tl + tr;
let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22);
let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0);
let axis = if raw_axis.abs() < 0.045 {
0.0
} else {
raw_axis
};
BreakoutControl {
axis,
top_force,
left_force,
right_force,
}
}
fn pressure_pause_threshold() -> f32 {
420.0_f32.max(((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN).max(1000.0) * 0.07).round())
}
fn paddle_rect(paddle_x: f32) -> egui::Rect {
egui::Rect::from_center_size(
egui::pos2(paddle_x, PADDLE_Y),
egui::vec2(PADDLE_W, PADDLE_H),
)
}
fn circle_hits_rect(center: egui::Pos2, radius: f32, rect: egui::Rect) -> bool {
let closest_x = center.x.clamp(rect.left(), rect.right());
let closest_y = center.y.clamp(rect.top(), rect.bottom());
let dx = center.x - closest_x;
let dy = center.y - closest_y;
dx * dx + dy * dy <= radius * radius
}
fn status_chip(ui: &mut egui::Ui, label: &'static str, value: impl ToString, color: egui::Color32) {
egui::Frame::new()
.fill(color_alpha(ONE_DARK_PRO.panel_deep, 190))
.stroke(egui::Stroke::new(1.0_f32, color_alpha(color, 92)))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 4))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.colored_label(ONE_DARK_PRO.text_subtle, label);
ui.colored_label(color, value.to_string());
});
});
}
fn status_color(phase: BreakoutPhase) -> egui::Color32 {
match phase {
BreakoutPhase::Idle => ONE_DARK_PRO.text_dim,
BreakoutPhase::Running => ACCENT_GREEN,
BreakoutPhase::Paused => ACCENT_ORANGE,
BreakoutPhase::Over => ACCENT_RED,
}
}
fn to_screen(rect: egui::Rect, point: egui::Pos2) -> egui::Pos2 {
egui::pos2(
rect.left() + point.x * rect.width(),
rect.top() + point.y * rect.height(),
)
}
fn to_screen_rect(rect: egui::Rect, local: egui::Rect) -> egui::Rect {
egui::Rect::from_min_max(
rect.left_top() + local.min.to_vec2() * rect.size(),
rect.left_top() + local.max.to_vec2() * rect.size(),
)
}
fn paint_arena_grid(painter: &egui::Painter, rect: egui::Rect) {
// 游戏背景保持纯净,避免网格和外框干扰砖块本身的色块节奏。
painter.rect_filled(
rect,
egui::CornerRadius::same(6),
egui::Color32::from_rgb(2, 8, 10),
);
painter.rect_stroke(
rect,
egui::CornerRadius::same(6),
egui::Stroke::new(1.0_f32, color_alpha(ONE_DARK_PRO.accent, 70)),
egui::StrokeKind::Inside,
);
}
fn paint_brick(painter: &egui::Painter, arena: egui::Rect, brick: &Brick, index: usize) {
let rect = to_screen_rect(arena, brick.rect);
if brick.alive {
let row = index / BRICK_COLS;
let col = index % BRICK_COLS;
// 参考示例图:纯色矩形砖块,黑色间隔由砖块之间的空隙自然露出。
painter.rect_filled(rect, egui::CornerRadius::ZERO, brick_color(row, col));
} else if brick.flash > 0.0 {
let alpha = (brick.flash * 150.0) as u8;
painter.rect_stroke(
rect.expand(4.0 * brick.flash),
egui::CornerRadius::same(4),
egui::Stroke::new(1.4_f32, color_alpha(ONE_DARK_PRO.accent_hot, alpha)),
egui::StrokeKind::Outside,
);
}
}
fn paint_paddle(painter: &egui::Painter, arena: egui::Rect, paddle_x: f32) {
let rect = to_screen_rect(arena, paddle_rect(paddle_x));
// 托盘也改成纯色块,和砖块保持同一种视觉语言。
painter.rect_filled(rect, egui::CornerRadius::ZERO, ACCENT_BLUE);
}
fn paint_ball(painter: &egui::Painter, arena: egui::Rect, ball_pos: egui::Pos2) {
let center = to_screen(arena, ball_pos);
let radius = BALL_RADIUS * arena.width().min(arena.height());
// 球保留为一个普通实心圆,避免额外高光和光晕。
painter.circle_filled(center, radius, egui::Color32::from_rgb(241, 207, 70));
}
fn paint_control_meter(painter: &egui::Painter, arena: egui::Rect, control: BreakoutControl) {
let meter_width = arena.width().mul_add(0.30, 0.0).clamp(180.0, 240.0);
let meter = egui::Rect::from_min_size(
arena.left_bottom() + egui::vec2(18.0, -34.0),
egui::vec2(meter_width, 18.0),
);
painter.rect_filled(
meter,
egui::CornerRadius::same(4),
color_alpha(ONE_DARK_PRO.panel_deep, 220),
);
painter.rect_stroke(
meter,
egui::CornerRadius::same(4),
egui::Stroke::new(1.0_f32, color_alpha(ONE_DARK_PRO.border, 120)),
egui::StrokeKind::Outside,
);
let center_x = meter.center().x;
painter.line_segment(
[
egui::pos2(center_x, meter.top()),
egui::pos2(center_x, meter.bottom()),
],
egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border_soft),
);
let marker_x = center_x + control.axis.clamp(-1.0, 1.0) * meter.width() * 0.45;
painter.circle_filled(
egui::pos2(marker_x, meter.center().y),
5.0,
color_alpha(ACCENT_GREEN, 210),
);
painter.circle_filled(
egui::pos2(marker_x, meter.center().y),
2.3,
color_alpha(egui::Color32::WHITE, 150),
);
painter.text(
meter.right_center() + egui::vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
format!(
"L {:>4.0} R {:>4.0} T {:>4.0}",
control.left_force, control.right_force, control.top_force
),
egui::FontId::monospace(10.0),
ONE_DARK_PRO.text_dim,
);
}
fn paint_center_overlay(painter: &egui::Painter, rect: egui::Rect, phase: BreakoutPhase) {
painter.rect_filled(
rect,
egui::CornerRadius::same(6),
color_alpha(egui::Color32::BLACK, 82),
);
let (title, detail) = match phase {
BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"),
BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"),
BreakoutPhase::Over => ("游戏结束", "点击或空格重开"),
BreakoutPhase::Running => ("", ""),
};
painter.text(
rect.center() - egui::vec2(0.0, 10.0),
egui::Align2::CENTER_CENTER,
title,
egui::FontId::proportional(24.0),
ONE_DARK_PRO.text,
);
painter.text(
rect.center() + egui::vec2(0.0, 20.0),
egui::Align2::CENTER_CENTER,
detail,
egui::FontId::proportional(13.0),
ONE_DARK_PRO.text_dim,
);
}
fn brick_color(row: usize, col: usize) -> egui::Color32 {
const PALETTE: [egui::Color32; 5] = [
egui::Color32::from_rgb(46, 178, 104),
egui::Color32::from_rgb(73, 188, 220),
egui::Color32::from_rgb(234, 199, 73),
egui::Color32::from_rgb(232, 125, 64),
egui::Color32::from_rgb(237, 68, 62),
];
PALETTE[(col + row * 2) % PALETTE.len()]
}
fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 {
egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha)
}

View File

@@ -4,7 +4,10 @@ use std::time::Duration;
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
use crate::serial_core::serial::{SerialPortReadWrite, run_serial_loop};
use crate::recording::Recorder;
use crate::serial_core::serial::{
SerialIoStats, SerialPortReadWrite, SerialProtocol, run_serial_loop,
};
/// Connection state visible to the UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -27,6 +30,9 @@ struct Session {
cancel_tx: Sender<()>,
handle: JoinHandle<()>,
sample_rx: Receiver<Vec<i32>>,
stats_rx: Receiver<SerialIoStats>,
rows: u32,
cols: u32,
}
/// Thread-safe connection manager that the UI and renderer can share.
@@ -34,6 +40,7 @@ pub struct ConnectionManager {
state: Arc<Mutex<ConnectionState>>,
session: Arc<Mutex<Option<Session>>>,
latest_sample: Arc<Mutex<Option<PressureSample>>>,
stats: Arc<Mutex<SerialIoStats>>,
}
impl ConnectionManager {
@@ -42,6 +49,7 @@ impl ConnectionManager {
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
session: Arc::new(Mutex::new(None)),
latest_sample: Arc::new(Mutex::new(None)),
stats: Arc::new(Mutex::new(SerialIoStats::default())),
}
}
@@ -53,8 +61,31 @@ impl ConnectionManager {
*self.state.lock().unwrap() = new_state;
}
pub fn stats(&self) -> SerialIoStats {
let session = self.session.lock().unwrap();
if let Some(ref session) = *session {
loop {
match session.stats_rx.try_recv() {
Ok(stats) => *self.stats.lock().unwrap() = stats,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
}
*self.stats.lock().unwrap()
}
/// Connect to the given serial port and start streaming in a background thread.
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) {
pub fn connect(
&self,
port_name: &str,
rows: u32,
cols: u32,
baud_rate: u32,
protocol: SerialProtocol,
recorder: Recorder,
) {
self.disconnect();
self.set_state(ConnectionState::Connecting);
@@ -64,16 +95,22 @@ impl ConnectionManager {
let latest_sample = Arc::clone(&self.latest_sample);
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
let (sample_tx, sample_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
let (stats_tx, stats_rx) = crossbeam_channel::bounded::<SerialIoStats>(16);
*self.stats.lock().unwrap() = SerialIoStats::default();
let handle = thread::spawn(move || {
let result = run_device_loop(
&port,
rows,
cols,
baud_rate,
protocol,
&state,
&cancel_rx,
&sample_tx,
&stats_tx,
&latest_sample,
recorder,
);
if let Err(e) = result {
eprintln!("[connection] device loop error: {e}");
@@ -85,6 +122,9 @@ impl ConnectionManager {
cancel_tx,
handle,
sample_rx,
stats_rx,
rows,
cols,
});
}
@@ -102,6 +142,7 @@ impl ConnectionManager {
self.set_state(ConnectionState::Disconnected);
*self.latest_sample.lock().unwrap() = None;
*self.stats.lock().unwrap() = SerialIoStats::default();
}
/// Drain pending samples (non-blocking) and return the last one.
@@ -112,10 +153,12 @@ impl ConnectionManager {
loop {
match session.sample_rx.try_recv() {
Ok(vals) => {
let rows = 12u32;
let cols = 7u32;
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
last = Some(PressureSample { matrix, rows, cols });
last = Some(PressureSample {
matrix,
rows: session.rows,
cols: session.cols,
});
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
@@ -136,17 +179,22 @@ impl Default for ConnectionManager {
}
/// The blocking device loop that runs on a background thread.
#[allow(clippy::too_many_arguments)]
fn run_device_loop(
port_name: &str,
rows: u32,
cols: u32,
baud_rate: u32,
protocol: SerialProtocol,
state: &Arc<Mutex<ConnectionState>>,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
stats_tx: &Sender<SerialIoStats>,
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
recorder: Recorder,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let port = serialport::new(port_name, 921_600)
.timeout(Duration::from_millis(100))
let port = serialport::new(port_name, baud_rate)
.timeout(Duration::from_millis(1))
.open()?;
*state.lock().unwrap() = ConnectionState::Connected;
@@ -154,36 +202,19 @@ fn run_device_loop(
let mut rw = SerialPortReadWrite::new(port);
*state.lock().unwrap() = ConnectionState::Streaming;
// We need to also forward samples to latest_sample
let (inner_tx, inner_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
let latest = Arc::clone(latest_sample);
let outer_tx = sample_tx.clone();
run_serial_loop(
&mut rw,
rows as usize,
cols as usize,
protocol,
cancel_rx,
sample_tx,
Some(stats_tx),
Some(&recorder),
);
// Bridge thread: reads from inner channel, forwards to both sample_tx and latest_sample
let bridge_cancel = cancel_rx.clone();
let bridge_handle = thread::spawn(move || {
loop {
if bridge_cancel.try_recv().is_ok() {
break;
}
match inner_rx.try_recv() {
Ok(vals) => {
// Store latest
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
*latest.lock().unwrap() = Some(PressureSample { matrix, rows, cols });
// Forward
let _ = outer_tx.try_send(vals);
}
Err(TryRecvError::Empty) => {
std::thread::sleep(Duration::from_millis(1));
}
Err(TryRecvError::Disconnected) => break,
}
}
});
run_serial_loop(&mut rw, rows as usize, cols as usize, cancel_rx, &inner_tx);
let _ = bridge_handle.join();
if let Ok(mut latest) = latest_sample.lock() {
*latest = None;
}
Ok(())
}

49
src/force.rs Normal file
View File

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

View File

@@ -1,5 +1,9 @@
#![allow(dead_code)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app;
mod breakout;
mod connection;
mod force;
mod matrix;
mod model;
mod recording;

View File

@@ -1,5 +1,31 @@
pub const MATRIX_ROWS: u32 = 12;
pub const MATRIX_COLS: u32 = 7;
pub const UNFOLDED_L_CHANNEL_COUNT: usize = 8;
pub const UNFOLDED_H_CHANNEL_COUNT: usize = 13;
pub const UNFOLDED_SENSOR_COUNT: usize = UNFOLDED_L_CHANNEL_COUNT * UNFOLDED_H_CHANNEL_COUNT;
pub const UNFOLDED_SENSOR_SEGMENT_COUNTS: [usize; 10] = [8, 3, 5, 8, 10, 44, 3, 5, 8, 10];
pub const fn unfolded_lh_sample_index(l: usize, h: usize) -> usize {
debug_assert!(l < UNFOLDED_L_CHANNEL_COUNT);
debug_assert!(h < UNFOLDED_H_CHANNEL_COUNT);
h * UNFOLDED_L_CHANNEL_COUNT + l
}
#[cfg(test)]
mod adc_scan_tests {
use super::*;
#[test]
fn scan_changes_l_before_advancing_h() {
let first_h0_scan = (0..UNFOLDED_L_CHANNEL_COUNT)
.map(|l| unfolded_lh_sample_index(l, 0))
.collect::<Vec<_>>();
assert_eq!(first_h0_scan, (0..8).collect::<Vec<_>>());
assert_eq!(unfolded_lh_sample_index(0, 1), 8);
assert_eq!(unfolded_lh_sample_index(7, 12), 103);
}
}
const BASE_MATRIX_SPAN: f32 = 24.0;
const MATRIX_SPAN_GROWTH: f32 = 0.6;

View File

@@ -145,10 +145,10 @@ impl Recorder {
r.state == RecordingState::Recording || r.state == RecordingState::Paused,
"nothing to stop"
);
if r.state == RecordingState::Paused {
if let Some(ps) = r.pause_start.take() {
r.paused_duration_ms += ps.elapsed().as_millis() as u64;
}
if r.state == RecordingState::Paused
&& let Some(ps) = r.pause_start.take()
{
r.paused_duration_ms += ps.elapsed().as_millis() as u64;
}
r.state = RecordingState::Idle;
Ok(())
@@ -317,15 +317,14 @@ impl Recorder {
}
// Set the start instant so duration_ms() reports the imported span
if !r.frames.is_empty() {
if let Some(last) = r.frames.last() {
// Pretend the recording happened `last.timestamp_ms` ago
// so that elapsed_ms() would return that value.
// We store a "fake" start by noting the offset.
r.start =
Some(Instant::now() - std::time::Duration::from_millis(last.timestamp_ms));
r.paused_duration_ms = 0;
}
if !r.frames.is_empty()
&& let Some(last) = r.frames.last()
{
// Pretend the recording happened `last.timestamp_ms` ago
// so that elapsed_ms() would return that value.
// We store a "fake" start by noting the offset.
r.start = Some(Instant::now() - std::time::Duration::from_millis(last.timestamp_ms));
r.paused_duration_ms = 0;
}
Ok(())

View File

@@ -1,5 +1,8 @@
use crate::{
matrix::{MatrixLayout, build_view_projection, glyph_world_position},
matrix::{
MatrixLayout, UNFOLDED_SENSOR_COUNT, build_view_projection, glyph_world_position,
unfolded_lh_sample_index,
},
model::{AlphaMode, InstanceRaw, ModelVertex, Vertex},
resources, texture,
};
@@ -13,11 +16,13 @@ use std::ops::Range;
pub const PRESSURE_CELL_COUNT: usize =
(crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize;
pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT];
pub type PressureSamples = Vec<[f32; 2]>;
pub struct WgpuBackgroundCallback {
pub width: f32,
pub height: f32,
pub pressure: PressureFrame,
pub hand_pressure: PressureSamples,
pub active_mode: ActiveMode,
}
@@ -68,6 +73,97 @@ const HAND_TIP_MATRICES: [HandTipMatrix; 5] = [
},
];
const UNFOLDED_CANVAS_SIZE: [f32; 2] = [850.0, 750.0];
const UNFOLDED_CELL_SPACING_PX: f32 = 42.0;
const UNFOLDED_SENSOR_CHIPS: [HandPalmChip; 10] = [
// Top cap: 2 rows x 4 columns.
HandPalmChip {
center_px: [425.0, 165.0],
size_px: [176.0, 88.0],
angle_rad: 0.0,
rows: 2,
cols: 4,
sample_offset: 0,
},
// Left wing, authored from the outside towards the 11x4 center block.
HandPalmChip {
center_px: [140.0, 578.0],
size_px: [46.0, 132.0],
angle_rad: 0.0,
rows: 3,
cols: 1,
sample_offset: 8,
},
HandPalmChip {
center_px: [188.0, 534.0],
size_px: [46.0, 220.0],
angle_rad: 0.0,
rows: 5,
cols: 1,
sample_offset: 11,
},
HandPalmChip {
center_px: [236.0, 468.0],
size_px: [46.0, 352.0],
angle_rad: 0.0,
rows: 8,
cols: 1,
sample_offset: 16,
},
HandPalmChip {
center_px: [284.0, 424.0],
size_px: [46.0, 440.0],
angle_rad: 0.0,
rows: 10,
cols: 1,
sample_offset: 24,
},
// Center spine: 11 rows x 4 columns.
HandPalmChip {
center_px: [425.0, 444.0],
size_px: [176.0, 484.0],
angle_rad: 0.0,
rows: 11,
cols: 4,
sample_offset: 34,
},
// Right wing mirrors the left wing. Data remains ordered 3, 5, 8, 10.
HandPalmChip {
center_px: [710.0, 578.0],
size_px: [46.0, 132.0],
angle_rad: 0.0,
rows: 3,
cols: 1,
sample_offset: 78,
},
HandPalmChip {
center_px: [662.0, 534.0],
size_px: [46.0, 220.0],
angle_rad: 0.0,
rows: 5,
cols: 1,
sample_offset: 81,
},
HandPalmChip {
center_px: [614.0, 468.0],
size_px: [46.0, 352.0],
angle_rad: 0.0,
rows: 8,
cols: 1,
sample_offset: 86,
},
HandPalmChip {
center_px: [566.0, 424.0],
size_px: [46.0, 440.0],
angle_rad: 0.0,
rows: 10,
cols: 1,
sample_offset: 94,
},
];
const HAND_FINGER_SENSOR_CELLS: usize = 12 * 7;
// Each entry pins one miniature matrix to a fingertip in hand.png.
// Coordinates are authored in source-image pixels so they are easy to tune by eye.
// size_px keeps the same 7:12 aspect as the Finger-mode 7 columns x 12 rows matrix.
@@ -77,6 +173,17 @@ struct HandTipMatrix {
angle_rad: f32,
}
// One block in the flat 270-degree sensor layout. Coordinates use a dedicated
// 850x750 canvas so the unfolded shape stays prominent across window sizes.
struct HandPalmChip {
center_px: [f32; 2],
size_px: [f32; 2],
angle_rad: f32,
rows: u32,
cols: u32,
sample_offset: usize,
}
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
fn prepare(
&self,
@@ -87,7 +194,13 @@ impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
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);
resources.prepare(
queue,
self.width,
self.height,
&self.pressure,
&self.hand_pressure,
);
Vec::new()
}
@@ -115,15 +228,21 @@ pub struct BackgroundRenderResources {
hand_image_pipeline: wgpu::RenderPipeline,
glyph_pipeline: wgpu::RenderPipeline,
dot_pipeline: wgpu::RenderPipeline,
hand_membrane_pipeline: wgpu::RenderPipeline,
hand_dot_pipeline: wgpu::RenderPipeline,
hand_palm_dot_pipeline: wgpu::RenderPipeline,
hand_image_bind_group: wgpu::BindGroup,
hand_image_texture: texture::Texture,
glyph_vertex_buffer: wgpu::Buffer,
glyph_instance_buffer: wgpu::Buffer,
glyph_instances: Vec<GlyphInstance>,
hand_membrane_instance_buffer: wgpu::Buffer,
hand_membrane_instances: Vec<GlyphInstance>,
hand_dot_instance_buffer: wgpu::Buffer,
hand_dot_instances: Vec<GlyphInstance>,
hand_palm_dot_instance_buffer: wgpu::Buffer,
hand_palm_dot_instances: Vec<GlyphInstance>,
render_options: RenderOptions,
}
@@ -229,10 +348,7 @@ impl BackgroundRenderResources {
build_view_projection(1.0, &layout),
surface_is_srgb,
render_options,
[
hand_image_texture.width as f32,
hand_image_texture.height as f32,
],
UNFOLDED_CANVAS_SIZE,
);
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Pressure Matrix Uniform Buffer"),
@@ -445,8 +561,12 @@ impl BackgroundRenderResources {
let glyph_pipeline =
create_glyph_pipeline(device, target_format, &shader, &pipeline_layout);
let dot_pipeline = create_dot_pipeline(device, target_format, &shader, &pipeline_layout);
let hand_membrane_pipeline =
create_hand_membrane_pipeline(device, target_format, &shader, &pipeline_layout);
let hand_dot_pipeline =
create_hand_dot_pipeline(device, target_format, &shader, &pipeline_layout);
let hand_palm_dot_pipeline =
create_hand_palm_dot_pipeline(device, target_format, &shader, &pipeline_layout);
let _model_pipelines =
create_model_pipelines(device, target_format, &shader, &model_pipeline_layout);
@@ -474,6 +594,16 @@ impl BackgroundRenderResources {
contents: bytemuck::cast_slice(&glyph_instances),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
let hand_membrane_instances = build_hand_membrane_instances(
hand_image_texture.width as f32,
hand_image_texture.height as f32,
);
let hand_membrane_instance_buffer =
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Hand Fingertip Membrane Instance Buffer"),
contents: bytemuck::cast_slice(&hand_membrane_instances),
usage: wgpu::BufferUsages::VERTEX,
});
let hand_dot_instances = build_hand_dot_instances(
rows,
cols,
@@ -487,6 +617,14 @@ impl BackgroundRenderResources {
contents: bytemuck::cast_slice(&hand_dot_instances),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
let hand_palm_dot_instances =
build_hand_palm_dot_instances(rows, cols, &[[0.0, 0.0]; PRESSURE_CELL_COUNT]);
let hand_palm_dot_instance_buffer =
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Hand Palm Chip Dot Instance Buffer"),
contents: bytemuck::cast_slice(&hand_palm_dot_instances),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
});
Self {
layout,
@@ -500,19 +638,32 @@ impl BackgroundRenderResources {
hand_image_pipeline,
glyph_pipeline,
dot_pipeline,
hand_membrane_pipeline,
hand_dot_pipeline,
hand_palm_dot_pipeline,
hand_image_bind_group,
hand_image_texture,
glyph_vertex_buffer,
glyph_instance_buffer,
glyph_instances,
hand_membrane_instance_buffer,
hand_membrane_instances,
hand_dot_instance_buffer,
hand_dot_instances,
hand_palm_dot_instance_buffer,
hand_palm_dot_instances,
render_options,
}
}
fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &PressureFrame) {
fn prepare(
&mut self,
queue: &wgpu::Queue,
width: f32,
height: f32,
pressure: &PressureFrame,
hand_pressure: &[[f32; 2]],
) {
let aspect = width / height.max(1.0);
self.uniform = MatrixUniform::new(
width,
@@ -520,10 +671,7 @@ impl BackgroundRenderResources {
build_view_projection(aspect, &self.layout),
self.surface_is_srgb,
self.render_options,
[
self.hand_image_texture.width as f32,
self.hand_image_texture.height as f32,
],
UNFOLDED_CANVAS_SIZE,
);
queue.write_buffer(
&self.uniform_buffer,
@@ -544,20 +692,35 @@ impl BackgroundRenderResources {
bytemuck::cast_slice(&self.glyph_instances),
);
// Hand mode uses five UV-anchored fingertip matrices over hand.png.
// Rebuild their instance positions here so pressure colors update every frame.
let hand_pressure = if hand_pressure.is_empty() {
pressure.as_slice()
} else {
hand_pressure
};
// Keep legacy fingertip buffers current while both hardware modes share
// the same renderer resources.
self.hand_dot_instances = build_hand_dot_instances(
self.rows,
self.cols,
self.hand_image_texture.width as f32,
self.hand_image_texture.height as f32,
pressure,
hand_pressure,
);
queue.write_buffer(
&self.hand_dot_instance_buffer,
0,
bytemuck::cast_slice(&self.hand_dot_instances),
);
// Rebuild the 104-cell unfolded layout with the latest gateway samples.
self.hand_palm_dot_instances =
build_hand_palm_dot_instances(self.rows, self.cols, hand_pressure);
queue.write_buffer(
&self.hand_palm_dot_instance_buffer,
0,
bytemuck::cast_slice(&self.hand_palm_dot_instances),
);
}
fn paint(&self, render_pass: &mut wgpu::RenderPass<'_>, active_mode: &ActiveMode) {
@@ -568,12 +731,7 @@ impl BackgroundRenderResources {
match active_mode {
ActiveMode::Finger(mode) => self.paint_finger(render_pass, mode),
ActiveMode::Hand(mode) => {
render_pass.set_pipeline(&self.hand_image_pipeline);
render_pass.set_bind_group(1, &self.hand_image_bind_group, &[]);
render_pass.draw(0..6, 0..1);
self.paint_hand(render_pass, mode);
}
ActiveMode::Hand(mode) => self.paint_hand(render_pass, mode),
}
}
@@ -595,11 +753,11 @@ impl BackgroundRenderResources {
fn paint_hand(&self, render_pass: &mut wgpu::RenderPass<'_>, mode: &HandGatewayMode) {
let _range = mode.range.clone();
// Draw the five fingertip dot matrices after hand.png, so they sit on top of the texture.
render_pass.set_pipeline(&self.hand_dot_pipeline);
// Match Finger mode: draw only the 104 independent pressure dots.
render_pass.set_pipeline(&self.hand_palm_dot_pipeline);
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
render_pass.set_vertex_buffer(1, self.hand_dot_instance_buffer.slice(..));
render_pass.draw(0..6, 0..self.hand_dot_instances.len() as u32);
render_pass.set_vertex_buffer(1, self.hand_palm_dot_instance_buffer.slice(..));
render_pass.draw(0..6, 0..self.hand_palm_dot_instances.len() as u32);
}
fn visible_instance_count(&self, rows: u32, cols: u32) -> u32 {
@@ -867,6 +1025,39 @@ fn create_dot_pipeline(
})
}
fn create_hand_membrane_pipeline(
device: &wgpu::Device,
target_format: &wgpu::TextureFormat,
shader: &wgpu::ShaderModule,
layout: &wgpu::PipelineLayout,
) -> wgpu::RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Hand Fingertip Sensor Membrane Pipeline"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_hand_membrane"),
compilation_options: Default::default(),
buffers: &[GlyphVertex::desc(), GlyphInstance::desc()],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_hand_membrane"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: *target_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
}
fn create_hand_dot_pipeline(
device: &wgpu::Device,
target_format: &wgpu::TextureFormat,
@@ -900,24 +1091,78 @@ fn create_hand_dot_pipeline(
})
}
fn create_hand_palm_dot_pipeline(
device: &wgpu::Device,
target_format: &wgpu::TextureFormat,
shader: &wgpu::ShaderModule,
layout: &wgpu::PipelineLayout,
) -> wgpu::RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Hand Palm Embedded Chip Dot Pipeline"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_hand_palm_dot"),
compilation_options: Default::default(),
buffers: &[GlyphVertex::desc(), GlyphInstance::desc()],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_hand_palm_dot"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: *target_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
}
fn build_hand_membrane_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> {
HAND_TIP_MATRICES
.iter()
.map(|tip| {
let uv_x = tip.center_px[0] / image_width.max(1.0);
let uv_y = tip.center_px[1] / image_height.max(1.0);
let membrane_size = [tip.size_px[0] * 1.62, tip.size_px[1] * 1.56];
GlyphInstance {
// Membrane shaders read xy as hand.png UV.
world_position: [uv_x, uv_y, 0.0, 1.0],
// Store angle and an oversized source-image pixel size for the floating sensor film.
style: [tip.angle_rad, membrane_size[0], membrane_size[1], 0.0],
}
})
.collect()
}
fn build_hand_dot_instances(
rows: u32,
cols: u32,
image_width: f32,
image_height: f32,
pressure: &PressureFrame,
pressure: &[[f32; 2]],
) -> Vec<GlyphInstance> {
let mut instances = Vec::with_capacity(HAND_TIP_MATRICES.len() * rows as usize * cols as usize);
for tip in HAND_TIP_MATRICES {
for (tip_index, tip) in HAND_TIP_MATRICES.into_iter().enumerate() {
let cos = tip.angle_rad.cos();
let sin = tip.angle_rad.sin();
for row in 0..rows {
for col in 0..cols {
let index = (row * cols + col) as usize;
let [normalized, display_value] =
pressure.get(index).copied().unwrap_or([0.0, 0.0]);
let [normalized, display_value] = sample_pressure_at(
pressure,
tip_index * HAND_FINGER_SENSOR_CELLS + index,
index,
);
// Lay out a rows x cols matrix in fingertip-local pixel space.
let local_x = (col as f32 - cols as f32 / 2.0 + 0.5) / cols as f32 * tip.size_px[0];
@@ -941,6 +1186,309 @@ fn build_hand_dot_instances(
instances
}
fn build_hand_palm_dot_instances(
_rows: u32,
_cols: u32,
pressure: &[[f32; 2]],
) -> Vec<GlyphInstance> {
let chip_dot_count: usize = UNFOLDED_SENSOR_CHIPS
.iter()
.map(|chip| (chip.rows * chip.cols) as usize)
.sum();
debug_assert_eq!(chip_dot_count, UNFOLDED_SENSOR_COUNT);
let mut instances = Vec::with_capacity(chip_dot_count);
for (chip_index, chip) in UNFOLDED_SENSOR_CHIPS.iter().enumerate() {
for row in 0..chip.rows {
for col in 0..chip.cols {
let index = unfolded_adc_sample_index(chip_index, row, col);
let [normalized, display_value] = sample_pressure_at(pressure, index, index);
let [x, y] = unfolded_cell_position(&chip, row, col);
instances.push(GlyphInstance {
world_position: [
x / UNFOLDED_CANVAS_SIZE[0],
y / UNFOLDED_CANVAS_SIZE[1],
0.0,
1.0,
],
style: [normalized, display_value, 0.0, 0.0],
});
}
}
}
instances
}
fn unfolded_adc_sample_index(chip_index: usize, row: u32, col: u32) -> usize {
// Raw sample 0 is L0H0. The scan advances L first:
// L0H0, L1H0, …, L7H0, L0H1, …, L7H12.
// The PCB unfolds those channel pairs into the ten visual regions below.
let (l, h) = match chip_index {
0 => (5 - col, 12 - row), // top cap: L5…L2 × H12…H11
1 => (6, row), // left outer tip: L6 × H0…H2
2 => (7, folded_five_row_h(row)), // left folded wing
3 => (7, 10 - row), // left inner wing: L7 × H10…H3
4 => (6, 12 - row), // left inner spine: L6 × H12…H3
5 => (5 - col, 10 - row), // center: L5…L2 × H10…H0
6 => (1, row), // right outer tip: L1 × H0…H2
7 => (0, folded_five_row_h(row)), // right folded wing
8 => (0, 10 - row), // right inner wing: L0 × H10…H3
9 => (1, 12 - row), // right inner spine: L1 × H12…H3
_ => unreachable!("invalid unfolded PCB region"),
};
unfolded_lh_sample_index(l as usize, h as usize)
}
fn folded_five_row_h(row: u32) -> u32 {
const PCB_H_ROUTE: [u32; 5] = [12, 11, 0, 1, 2];
PCB_H_ROUTE[row as usize]
}
fn unfolded_cell_position(chip: &HandPalmChip, row: u32, col: u32) -> [f32; 2] {
let cos = chip.angle_rad.cos();
let sin = chip.angle_rad.sin();
// Author every region on the same point grid. Using the last row as the
// vertical anchor keeps the stepped wing columns exactly bottom-aligned.
let bottom_center_y = chip.center_px[1] + chip.size_px[1] * 0.5 - 22.0;
let local_x = (col as f32 - chip.cols as f32 / 2.0 + 0.5) * UNFOLDED_CELL_SPACING_PX;
let y_from_bottom = (chip.rows.saturating_sub(row + 1)) as f32 * UNFOLDED_CELL_SPACING_PX;
let local_y = -y_from_bottom;
[
chip.center_px[0] + local_x * cos - local_y * sin,
bottom_center_y + local_x * sin + local_y * cos,
]
}
fn sample_pressure_at(pressure: &[[f32; 2]], index: usize, fallback_index: usize) -> [f32; 2] {
pressure
.get(index)
.or_else(|| pressure.get(fallback_index))
.copied()
.unwrap_or([0.0, 0.0])
}
#[cfg(test)]
mod unfolded_layout_tests {
use super::*;
use crate::matrix::UNFOLDED_SENSOR_SEGMENT_COUNTS;
#[test]
fn unfolded_layout_maps_every_lh_adc_channel_once() {
let mut seen = [false; UNFOLDED_SENSOR_COUNT];
for (chip_index, chip) in UNFOLDED_SENSOR_CHIPS.iter().enumerate() {
for row in 0..chip.rows {
for col in 0..chip.cols {
let index = unfolded_adc_sample_index(chip_index, row, col);
assert!(index < UNFOLDED_SENSOR_COUNT);
assert!(!seen[index], "duplicate ADC sample index {index}");
seen[index] = true;
}
}
}
assert!(seen.into_iter().all(|mapped| mapped));
assert_eq!(unfolded_lh_sample_index(0, 0), 0); // first raw sample
assert_eq!(unfolded_lh_sample_index(1, 0), 1); // L changes first
assert_eq!(unfolded_lh_sample_index(7, 0), 7);
assert_eq!(unfolded_lh_sample_index(0, 1), 8); // then H advances
assert_eq!(unfolded_adc_sample_index(7, 0, 0), 96); // L0H12
assert_eq!(unfolded_adc_sample_index(6, 0, 0), 1); // L1H0
assert_eq!(unfolded_lh_sample_index(7, 12), 103); // last rendered sample
}
#[test]
fn unfolded_layout_matches_annotated_adc_intersections() {
// Right folded/outer intersections: 1=L0H0, 2=L1H0,
// 9=L0H1, 10=L1H1, 17=L0H2, 18=L1H2.
assert_eq!(unfolded_adc_sample_index(7, 2, 0) + 1, 1);
assert_eq!(unfolded_adc_sample_index(6, 0, 0) + 1, 2);
assert_eq!(unfolded_adc_sample_index(7, 3, 0) + 1, 9);
assert_eq!(unfolded_adc_sample_index(6, 1, 0) + 1, 10);
assert_eq!(unfolded_adc_sample_index(7, 4, 0) + 1, 17);
assert_eq!(unfolded_adc_sample_index(6, 2, 0) + 1, 18);
// Right inner routes continue upward from H3.
assert_eq!(
(0..8)
.map(|row| unfolded_adc_sample_index(8, row, 0) + 1)
.collect::<Vec<_>>(),
vec![81, 73, 65, 57, 49, 41, 33, 25]
);
assert_eq!(
(0..10)
.map(|row| unfolded_adc_sample_index(9, row, 0) + 1)
.collect::<Vec<_>>(),
vec![98, 90, 82, 74, 66, 58, 50, 42, 34, 26]
);
// Left routes mirror the right side's PCB continuation.
assert_eq!(
(0..3)
.map(|row| unfolded_adc_sample_index(1, row, 0) + 1)
.collect::<Vec<_>>(),
vec![7, 15, 23]
);
assert_eq!(
(0..5)
.map(|row| unfolded_adc_sample_index(2, row, 0) + 1)
.collect::<Vec<_>>(),
vec![104, 96, 8, 16, 24]
);
assert_eq!(
(0..8)
.map(|row| unfolded_adc_sample_index(3, row, 0) + 1)
.collect::<Vec<_>>(),
vec![88, 80, 72, 64, 56, 48, 40, 32]
);
assert_eq!(
(0..10)
.map(|row| unfolded_adc_sample_index(4, row, 0) + 1)
.collect::<Vec<_>>(),
vec![103, 95, 87, 79, 71, 63, 55, 47, 39, 31]
);
// Center rows H0…H7, each ordered L5, L4, L3, L2.
for h in 0..=7 {
let row = 10 - h;
let expected = (2..=5)
.rev()
.map(|l| unfolded_lh_sample_index(l, h as usize) + 1)
.collect::<Vec<_>>();
let rendered = (0..4)
.map(|col| unfolded_adc_sample_index(5, row, col) + 1)
.collect::<Vec<_>>();
assert_eq!(rendered, expected);
}
}
#[test]
fn annotated_folded_points_share_the_same_physical_rows() {
let same_y = |left_chip: usize, left_row: u32, right_chip: usize, right_row: u32| {
let left = unfolded_cell_position(&UNFOLDED_SENSOR_CHIPS[left_chip], left_row, 0)[1];
let right = unfolded_cell_position(&UNFOLDED_SENSOR_CHIPS[right_chip], right_row, 0)[1];
assert!((left - right).abs() < 0.01);
};
// Left outer pairs: 7/8, 15/16, 23/24.
for row in 0..3 {
same_y(1, row, 2, row + 2);
}
// Right outer pairs: 2/1, 10/9, 18/17.
for row in 0..3 {
same_y(6, row, 7, row + 2);
}
// Inner continuations align by H despite belonging to different strips.
for row in 0..8 {
same_y(3, row, 4, row + 2);
same_y(8, row, 9, row + 2);
}
}
#[test]
fn unfolded_layout_matches_requested_shapes() {
let shapes = UNFOLDED_SENSOR_CHIPS.map(|chip| (chip.rows, chip.cols));
let sample_counts = UNFOLDED_SENSOR_CHIPS.map(|chip| (chip.rows * chip.cols) as usize);
assert_eq!(
shapes,
[
(2, 4),
(3, 1),
(5, 1),
(8, 1),
(10, 1),
(11, 4),
(3, 1),
(5, 1),
(8, 1),
(10, 1),
]
);
assert_eq!(sample_counts, UNFOLDED_SENSOR_SEGMENT_COUNTS);
}
#[test]
fn unfolded_wings_are_mirrored_around_center() {
let center_x = UNFOLDED_SENSOR_CHIPS[5].center_px[0];
for (left, right) in UNFOLDED_SENSOR_CHIPS[1..5]
.iter()
.zip(UNFOLDED_SENSOR_CHIPS[6..10].iter())
{
assert_eq!((left.rows, left.cols), (right.rows, right.cols));
assert_eq!(left.center_px[1], right.center_px[1]);
assert!(
((center_x - left.center_px[0]) - (right.center_px[0] - center_x)).abs() < 0.01
);
}
}
#[test]
fn unfolded_layout_fits_its_canvas() {
for chip in UNFOLDED_SENSOR_CHIPS {
let half_width = chip.size_px[0] * 0.5;
let half_height = chip.size_px[1] * 0.5;
assert!(chip.center_px[0] - half_width >= 0.0);
assert!(chip.center_px[0] + half_width <= UNFOLDED_CANVAS_SIZE[0]);
assert!(chip.center_px[1] - half_height >= 0.0);
assert!(chip.center_px[1] + half_height <= UNFOLDED_CANVAS_SIZE[1]);
}
}
#[test]
fn unfolded_wing_rows_share_one_bottom_baseline() {
let wing_indices = [1usize, 2, 3, 4, 6, 7, 8, 9];
let expected_y = unfolded_cell_position(
&UNFOLDED_SENSOR_CHIPS[wing_indices[0]],
UNFOLDED_SENSOR_CHIPS[wing_indices[0]].rows - 1,
0,
)[1];
for index in wing_indices {
let chip = &UNFOLDED_SENSOR_CHIPS[index];
let bottom_y = unfolded_cell_position(chip, chip.rows - 1, 0)[1];
assert!((bottom_y - expected_y).abs() < 0.01);
}
}
#[test]
fn unfolded_wings_are_compact_with_more_space_at_center() {
let left_x = UNFOLDED_SENSOR_CHIPS[1..5]
.iter()
.map(|chip| chip.center_px[0])
.collect::<Vec<_>>();
let wing_gap = left_x[1] - left_x[0];
assert!((wing_gap - 48.0).abs() < 0.01);
assert!(
left_x
.windows(2)
.all(|pair| (pair[1] - pair[0] - wing_gap).abs() < 0.01)
);
let center_left_x = unfolded_cell_position(&UNFOLDED_SENSOR_CHIPS[5], 0, 0)[0];
let left_inner_x = unfolded_cell_position(&UNFOLDED_SENSOR_CHIPS[4], 0, 0)[0];
assert!(center_left_x - left_inner_x > wing_gap);
}
#[test]
fn unfolded_top_block_sits_close_to_center_block() {
let top = &UNFOLDED_SENSOR_CHIPS[0];
let center = &UNFOLDED_SENSOR_CHIPS[5];
let top_bottom_y = unfolded_cell_position(top, top.rows - 1, 0)[1];
let center_top_y = unfolded_cell_position(center, 0, 0)[1];
assert!(center_top_y > top_bottom_y);
assert!(center_top_y - top_bottom_y < UNFOLDED_CELL_SPACING_PX * 2.0);
}
}
fn build_glyph_instances(
rows: u32,
cols: u32,

View File

@@ -457,6 +457,7 @@ fn create_color_material(
))
}
#[allow(clippy::too_many_arguments)]
fn create_material(
device: &wgpu::Device,
texture_bind_group_layout: &wgpu::BindGroupLayout,
@@ -702,6 +703,7 @@ fn append_gltf_node_meshes(
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn append_gltf_primitive_mesh(
mesh_name: &str,
primitive_index: usize,
@@ -969,7 +971,7 @@ fn rgba_from_chunks(
read_component: fn(&[u8]) -> u8,
) -> anyhow::Result<Vec<u8>> {
let pixel_width = channels * component_width;
if pixel_width == 0 || pixels.len() % pixel_width != 0 {
if pixel_width == 0 || !pixels.len().is_multiple_of(pixel_width) {
bail!("invalid glTF image byte length for {channels} channels");
}

View File

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

View File

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

View File

@@ -13,6 +13,15 @@ pub struct TactileACodec {
expected_data_len: usize,
}
impl From<u8> for TactileAFrameStatusCode {
fn from(value: u8) -> Self {
match value {
0 => TactileAFrameStatusCode::Success,
_ => TactileAFrameStatusCode::Failure,
}
}
}
impl TactileACodec {
pub fn new(cols: usize, rows: usize) -> TactileACodec {
Self {
@@ -22,7 +31,7 @@ impl TactileACodec {
}
pub fn parse_data_frame(data: &[u8]) -> Result<Vec<i32>, CodecError> {
if data.len() % 2 != 0 {
if !data.len().is_multiple_of(2) {
return Err(CodecError::InvalidLength);
}
@@ -37,7 +46,7 @@ impl TactileACodec {
Ok(vals)
}
pub fn build_req_frame(cols: usize, rows: usize) -> TactileAFrame {
pub fn build_req_frame(cols: usize, rows: usize) -> anyhow::Result<TactileAFrame> {
let header = [0x55, 0xAA];
let payload_len: usize = 9;
let device_addr: u8 = 0x34;
@@ -46,7 +55,7 @@ impl TactileACodec {
let start_addr: u32 = 7168;
let except_data_len: usize = cols * rows * 2;
let checksum: u8 = 0;
TactileAFrame::Req(TactileAReqFrame {
Ok(TactileAFrame::Req(TactileAReqFrame {
meta: TactileAFrameMetaData {
header,
payload_len,
@@ -57,7 +66,7 @@ impl TactileACodec {
except_data_len,
checksum,
},
})
}))
}
}
@@ -102,10 +111,7 @@ impl Codec<TactileAFrame> for TactileACodec {
self.buffer[10],
]);
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
let status = match self.buffer[13] {
0 => TactileAFrameStatusCode::Success,
_ => TactileAFrameStatusCode::Failure,
};
let status = TactileAFrameStatusCode::from(self.buffer[13]);
if except_data_len != self.expected_data_len {
log::debug!(
@@ -126,7 +132,7 @@ impl Codec<TactileAFrame> for TactileACodec {
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());
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={}",
@@ -182,3 +188,61 @@ impl Codec<TactileAFrame> for TactileACodec {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::serial_core::codec::Codec;
use std::time::Instant;
const FINGER_3D_ROWS: usize = 12;
const FINGER_3D_COLS: usize = 9;
const FINGER_3D_DATA_LEN: usize = FINGER_3D_ROWS * FINGER_3D_COLS * 2;
#[test]
fn finger_3d_request_asks_for_108_samples() {
let codec = TactileACodec::new(FINGER_3D_COLS, FINGER_3D_ROWS);
let frame = TactileACodec::build_req_frame(FINGER_3D_COLS, FINGER_3D_ROWS).unwrap();
let encoded = codec.encode(&frame).unwrap();
assert_eq!(
u16::from_le_bytes([encoded[11], encoded[12]]) as usize,
FINGER_3D_DATA_LEN
);
}
#[test]
fn finger_3d_decode_accepts_108_samples_across_reads() {
let mut codec = TactileACodec::new(FINGER_3D_COLS, FINGER_3D_ROWS);
let payload = (0..FINGER_3D_ROWS * FINGER_3D_COLS)
.flat_map(|value| (value as u16).to_le_bytes())
.collect::<Vec<_>>();
let mut response = Vec::new();
response.extend_from_slice(&[0xAA, 0x55]);
response.extend_from_slice(&9_u16.to_le_bytes());
response.extend_from_slice(&[0x34, 0x00, 0xFB]);
response.extend_from_slice(&7168_u32.to_le_bytes());
response.extend_from_slice(&(FINGER_3D_DATA_LEN as u16).to_le_bytes());
response.push(0);
response.extend_from_slice(&payload);
response.push(calc_crc8_itu(&response));
let split = response.len() / 2;
assert!(
codec
.decode(&response[..split], Instant::now())
.unwrap()
.is_empty()
);
let frames = codec.decode(&response[split..], Instant::now()).unwrap();
let TactileAFrame::Rep(rep) = &frames[0] else {
panic!("expected response frame");
};
assert_eq!(rep.payload.len(), FINGER_3D_DATA_LEN);
assert_eq!(
TactileACodec::parse_data_frame(&rep.payload).unwrap().len(),
108
);
}
}

View File

@@ -9,6 +9,8 @@ pub enum SerialError {
AlreadyConnected,
StateError,
NoRecordedData,
ExportError,
ImportError,
}
impl fmt::Display for SerialError {
@@ -21,6 +23,8 @@ impl fmt::Display for SerialError {
SerialError::AlreadyConnected => write!(f, "Already Connected"),
SerialError::StateError => write!(f, "State Error"),
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
SerialError::ExportError => write!(f, "Export Error"),
SerialError::ImportError => write!(f, "Import Error"),
}
}
}

View File

@@ -1,3 +1,13 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestFrame {
pub header: [u8; 2],
pub cmd: u8,
pub length: usize,
pub payload: Vec<u8>,
pub checksum: u8,
pub dts_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TactileAFrameMetaData {
pub header: [u8; 2],

View File

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

View File

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

View File

@@ -1,27 +1,71 @@
use crate::recording::Recorder;
use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::hand_gateway::{HandGatewayCodec, HandGatewayFrame};
use crate::serial_core::codecs::tactile_a::TactileACodec;
use crate::serial_core::frame::TactileAFrame;
use crate::serial_core::utils::elapsed_millis;
use crossbeam_channel::{Receiver, Sender, TryRecvError};
use crossbeam_channel::{Receiver, Sender};
use std::io::{Read, Write};
use std::time::{Duration, Instant};
const POLL_INTERVAL_MS: u64 = 10;
const POLL_INTERVAL_MS: u64 = 5;
const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70, 44];
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SerialIoStats {
pub rx_bytes: u64,
pub tx_bytes: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SerialProtocol {
TactileA,
HandGateway,
}
/// Runs the serial polling loop on the calling (background) thread.
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
#[allow(clippy::too_many_arguments)]
pub fn run_serial_loop(
port: &mut dyn ReadWrite,
rows: usize,
cols: usize,
protocol: SerialProtocol,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) {
match protocol {
SerialProtocol::TactileA => {
run_tactile_a_loop(port, rows, cols, cancel_rx, sample_tx, stats_tx, recorder)
}
SerialProtocol::HandGateway => {
run_hand_gateway_loop(port, cancel_rx, sample_tx, stats_tx, recorder)
}
}
}
fn run_tactile_a_loop(
port: &mut dyn ReadWrite,
rows: usize,
cols: usize,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) {
let session_started_at = Instant::now();
let mut codec = TactileACodec::new(cols, rows);
let req_frame = TactileACodec::build_req_frame(cols, rows);
let req_frame = match TactileACodec::build_req_frame(cols, rows) {
Ok(frame) => frame,
Err(err) => {
eprintln!("[serial] request frame build error: {err}");
return;
}
};
let mut buffer = [0u8; 1024];
let mut poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
let poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
let mut io_stats = SerialIoStats::default();
loop {
// Check cancel
@@ -30,8 +74,11 @@ pub fn run_serial_loop(
}
// Send poll request
if let Ok(req_bytes) = codec.encode(&req_frame) {
let _ = port.write_all(&req_bytes);
if let Ok(req_bytes) = codec.encode(&req_frame)
&& port.write_all(&req_bytes).is_ok()
{
io_stats.tx_bytes += req_bytes.len() as u64;
publish_stats(stats_tx, io_stats);
}
// Read response with poll interval
@@ -43,12 +90,19 @@ pub fn run_serial_loop(
match port.read(&mut buffer) {
Ok(n) if n > 0 => {
io_stats.rx_bytes += n as u64;
publish_stats(stats_tx, io_stats);
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
for frame in frames {
if let TactileAFrame::Rep(rep) = frame {
if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) {
let _ = sample_tx.try_send(vals);
if let TactileAFrame::Rep(rep) = frame
&& let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload)
{
if let Some(recorder) = recorder {
let pressures: Vec<u32> =
vals.iter().map(|v| (*v).max(0) as u32).collect();
recorder.add_frame(&pressures);
}
let _ = sample_tx.try_send(vals);
}
}
}
@@ -68,6 +122,109 @@ pub fn run_serial_loop(
}
}
fn run_hand_gateway_loop(
port: &mut dyn ReadWrite,
cancel_rx: &Receiver<()>,
sample_tx: &Sender<Vec<i32>>,
stats_tx: Option<&Sender<SerialIoStats>>,
recorder: Option<&Recorder>,
) {
let session_started_at = Instant::now();
let mut codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS);
let mut buffer = [0u8; 1024];
let poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
let mut io_stats = SerialIoStats::default();
loop {
if cancel_rx.try_recv().is_ok() {
break;
}
let deadline = Instant::now() + poll_interval;
loop {
if Instant::now() >= deadline {
break;
}
match port.read(&mut buffer) {
Ok(n) if n > 0 => {
io_stats.rx_bytes += n as u64;
publish_stats(stats_tx, io_stats);
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
for frame in frames {
let HandGatewayFrame::DataRep(rep) = frame;
// println!(
// "[hand-packet-raw] bytes={} timestamp_us={} config=0x{:08X} valid_config=0x{:08X} block_count={} payload_bytes={} raw={}",
// rep.raw.len(),
// rep.timestamp,
// rep.config,
// rep.valid_config,
// rep.block_count,
// rep.nodes
// .iter()
// .map(|node| node.payload.len())
// .sum::<usize>(),
// format_hex_bytes(&rep.raw)
// );
let mut vals = Vec::new();
let mut parse_ok = true;
for node in &rep.nodes {
match HandGatewayCodec::parse_node_payload(&node.payload) {
Ok(node_values) => {
vals.extend(node_values.into_iter().map(|raw| {
let raw = raw as i32;
if raw < 15 { 0 } else { raw }
}));
}
Err(err) => {
parse_ok = false;
eprintln!("[hand-packet-values] parse error: {err}");
break;
}
}
}
if parse_ok {
// println!("[hand-packet-values] samples={}", vals.len());
if let Some(recorder) = recorder {
let pressures: Vec<u32> =
vals.iter().map(|v| (*v).max(0) as u32).collect();
recorder.add_frame(&pressures);
}
let _ = sample_tx.try_send(vals);
}
}
}
}
Ok(_) => {
std::thread::sleep(Duration::from_millis(1));
}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
continue;
}
Err(e) => {
eprintln!("[serial] hand gateway read error: {e}");
return;
}
}
}
}
}
fn format_hex_bytes(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02X}"))
.collect::<Vec<_>>()
.join(" ")
}
fn publish_stats(stats_tx: Option<&Sender<SerialIoStats>>, stats: SerialIoStats) {
if let Some(tx) = stats_tx {
let _ = tx.try_send(stats);
}
}
/// Trait abstracting read+write for the serial port
pub trait ReadWrite: Send {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;

View File

@@ -1,10 +1,64 @@
use std::time::Instant;
pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] {
(n as u16).to_be_bytes()
}
pub fn usize_to_u16_le_bytes(n: usize) -> [u8; 2] {
(n as u16).to_be_bytes()
}
pub fn u16_to_hex_be_bytes(n: u16) -> [u8; 2] {
n.to_be_bytes()
}
pub fn u16_to_hex_le_bytes(n: u16) -> [u8; 2] {
n.to_le_bytes()
}
pub fn calc_crc8_smbus(c: &[u8]) -> u8 {
let crc8_smbus = crc::Crc::<u8>::new(&crc::CRC_8_SMBUS);
crc8_smbus.checksum(c)
}
pub fn calc_crc8_itu(c: &[u8]) -> u8 {
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
crc8_itu_alg.checksum(c)
}
pub fn calc_crc8_itu_xor55(c: &[u8]) -> u8 {
calc_crc8_itu(c) ^ 0x55
}
pub fn elapsed_millis(start_at: Instant) -> u64 {
start_at.elapsed().as_millis() as u64
}
#[cfg(test)]
mod test {
use crate::serial_core::utils::{calc_crc8_itu, calc_crc8_itu_xor55, calc_crc8_smbus};
#[test]
fn test_crc8_itu() {
let req_vec = vec![
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
];
let checksum = calc_crc8_itu(req_vec.as_slice());
assert_eq!(checksum, 0x7A);
}
#[test]
fn test_crc8_smbus() {
let req_vec = vec![
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
];
let checksum = calc_crc8_smbus(req_vec.as_slice());
assert_eq!(checksum, 0x2F);
}
#[test]
fn test_crc8_itu_xor55() {
let req_vec = vec![0x55, 0xAA, 0x07, 0x00, 0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF];
assert_eq!(calc_crc8_itu_xor55(req_vec.as_slice()), 0xFD);
}
}

View File

@@ -1,4 +1,4 @@
use eframe::egui;
use eframe::egui::{self, Color32};
#[derive(Clone, Copy)]
pub struct AppTheme {
@@ -29,7 +29,7 @@ pub struct DesignMetrics {
}
pub const ONE_DARK_PRO: AppTheme = AppTheme {
bg: egui::Color32::from_rgb(30, 40, 50),
bg: egui::Color32::BLACK,
panel: egui::Color32::from_rgb(22, 28, 35),
panel_strong: egui::Color32::from_rgb(34, 43, 54),
panel_deep: egui::Color32::from_rgb(15, 20, 27),
@@ -61,6 +61,7 @@ pub const METRICS: DesignMetrics = DesignMetrics {
pub mod layout {
pub const TITLE_BAR_HEIGHT: f32 = 36.0;
pub const CONFIG_BAR_HEIGHT: f32 = 48.0;
pub const CENTER_PANEL_TOP: f32 = 48.0;
pub const LEFT_X: f32 = 24.0;
pub const RIGHT_X: f32 = 1328.0;
@@ -76,7 +77,7 @@ pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
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.window_stroke = egui::Stroke::new(1.0_f32, theme.border);
visuals.extreme_bg_color = theme.panel_deep;
visuals.faint_bg_color = theme.panel_strong;
visuals.code_bg_color = theme.panel_deep;
@@ -84,23 +85,23 @@ pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
visuals.error_fg_color = ACCENT_RED;
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.noninteractive.bg_stroke = egui::Stroke::new(1.0_f32, theme.border_soft);
visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0_f32, 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.inactive.bg_stroke = egui::Stroke::new(1.0_f32, theme.border_soft);
visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0_f32, theme.text);
visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(44, 57, 70);
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, theme.accent);
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0_f32, theme.accent);
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0_f32, egui::Color32::WHITE);
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.active.bg_stroke = egui::Stroke::new(1.0_f32, theme.accent_hot);
visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0_f32, egui::Color32::WHITE);
visuals.widgets.open.bg_fill = egui::Color32::from_rgb(39, 50, 62);
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, theme.accent);
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, theme.text);
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0_f32, theme.accent);
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0_f32, theme.text);
visuals.selection.bg_fill = egui::Color32::from_rgb(35, 123, 140);
visuals.selection.stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
visuals.selection.stroke = egui::Stroke::new(1.0_f32, egui::Color32::WHITE);
visuals.hyperlink_color = theme.accent_hot;
ctx.set_visuals(visuals);
@@ -126,8 +127,8 @@ 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(),
"MapleMono-NF-CN-Bold".to_owned(),
egui::FontData::from_static(include_bytes!("../static/MapleMono-NF-CN-Bold.ttf")).into(),
);
let has_yahei = std::fs::read(r"C:\Windows\Fonts\msyh.ttc")
@@ -144,7 +145,7 @@ pub fn apply_fonts(ctx: &egui::Context) {
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "Hack-Bold".to_owned());
.insert(0, "MapleMono-NF-CN-Bold".to_owned());
if has_yahei {
fonts
.families
@@ -157,7 +158,7 @@ pub fn apply_fonts(ctx: &egui::Context) {
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.insert(0, "Hack-Bold".to_owned());
.insert(0, "MapleMono-NF-CN-Bold".to_owned());
if has_yahei {
fonts
.families
@@ -173,7 +174,7 @@ pub fn panel_frame(ctx: &egui::Context) -> egui::Frame {
let style = ctx.global_style();
egui::Frame::window(&style)
.fill(ONE_DARK_PRO.panel)
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
.stroke(egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border))
.corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius))
.inner_margin(egui::Margin::same(METRICS.panel_padding))
.shadow(egui::epaint::Shadow {
@@ -197,7 +198,7 @@ pub fn center_panel_frame() -> egui::Frame {
pub fn group_frame() -> egui::Frame {
egui::Frame::new()
.fill(ONE_DARK_PRO.panel_deep)
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft))
.stroke(egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border_soft))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(
METRICS.group_padding_x,
@@ -208,7 +209,20 @@ pub fn group_frame() -> egui::Frame {
pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
egui::Button::new(label)
.fill(ONE_DARK_PRO.panel_strong)
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
.stroke(egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border))
.corner_radius(egui::CornerRadius::same(4))
.min_size(egui::vec2(0.0, METRICS.button_height))
}
pub fn rich_tag_button(
label: impl Into<String>,
color: impl Into<Color32>,
) -> egui::Button<'static> {
let text = egui::RichText::new(label.into()).color(color);
egui::Button::new(text)
.fill(ONE_DARK_PRO.panel_strong)
.stroke(egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border))
.corner_radius(egui::CornerRadius::same(4))
.min_size(egui::vec2(0.0, METRICS.button_height))
}
@@ -216,7 +230,7 @@ pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
pub fn primary_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
egui::Button::new(label)
.fill(ONE_DARK_PRO.accent)
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.accent_hot))
.stroke(egui::Stroke::new(1.0_f32, ONE_DARK_PRO.accent_hot))
.corner_radius(egui::CornerRadius::same(4))
.min_size(egui::vec2(112.0, METRICS.button_height))
}
@@ -225,7 +239,7 @@ pub fn danger_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static
egui::Button::new(label)
.fill(ACCENT_RED)
.stroke(egui::Stroke::new(
1.0,
1.0_f32,
egui::Color32::from_rgb(255, 138, 126),
))
.corner_radius(egui::CornerRadius::same(4))
@@ -238,7 +252,7 @@ pub fn accent_button(
) -> egui::Button<'static> {
egui::Button::new(label)
.fill(fill)
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
.stroke(egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border))
.corner_radius(egui::CornerRadius::same(4))
.min_size(egui::vec2(0.0, METRICS.button_height))
}
@@ -257,7 +271,7 @@ pub fn mode_button(label: &'static str, selected: bool) -> egui::Button<'static>
egui::Button::new(egui::RichText::new(label).color(egui::Color32::WHITE))
.fill(fill)
.stroke(egui::Stroke::new(1.0, stroke))
.stroke(egui::Stroke::new(1.0_f32, stroke))
.corner_radius(egui::CornerRadius::same(4))
.min_size(egui::vec2(96.0, METRICS.button_height))
}
@@ -265,7 +279,7 @@ pub fn mode_button(label: &'static str, selected: bool) -> egui::Button<'static>
pub fn icon_button<'a>(icon: impl Into<egui::WidgetText>, size: egui::Vec2) -> egui::Button<'a> {
egui::Button::new(icon)
.fill(ONE_DARK_PRO.panel_strong)
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
.stroke(egui::Stroke::new(1.0_f32, ONE_DARK_PRO.border))
.corner_radius(egui::CornerRadius::same(4))
.min_size(size)
}

View File

@@ -187,6 +187,7 @@ impl Texture {
)
}
#[allow(clippy::too_many_arguments)]
pub fn from_rgba8_with_sampler(
device: &wgpu::Device,
queue: &wgpu::Queue,

View File

@@ -83,53 +83,6 @@ pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
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)

1214
src/ui.rs

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
use anyhow;
use serialport::available_ports;
pub fn serial_enum() -> anyhow::Result<Vec<String>> {

Binary file not shown.

Binary file not shown.

View File

@@ -126,15 +126,7 @@ fn vs_background(@builtin(vertex_index) vertex_index: u32) -> BackgroundVertexOu
@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.018, 0.019, 0.022), vec3f(0.038, 0.040, 0.046), 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.22;
color += vec3f(0.010, 0.010, 0.012) * (1.0 - smoothstep(0.0, 0.85, abs(uv.y - 0.50)));
return output_color(color, 1.0);
return output_color(vec3f(0.0, 0.0, 0.0), 1.0);
}
@@ -357,10 +349,8 @@ fn circle_alpha(local: vec2f, radius: f32, softness: f32) -> f32 {
return 1.0 - smoothstep(radius, radius + softness, dist);
}
// Convert a point authored in hand.png UV space into clip space.
// This mirrors fs_hand_image's aspect-fit math, so fingertip dots stay attached
// to the same image pixels when the app window changes shape.
fn hand_image_uv_to_clip(image_uv: vec2f) -> vec2f {
// Convert a point authored in sensor-canvas UV space into aspect-fitted clip space.
fn sensor_canvas_uv_to_clip(image_uv: vec2f) -> vec2f {
let viewport_aspect = u.viewport.x / max(u.viewport.y, 1.0);
let image_aspect = u.image.x / max(u.image.y, 1.0);
@@ -377,6 +367,100 @@ fn hand_image_uv_to_clip(image_uv: vec2f) -> vec2f {
return vec2f(screen_uv.x * 2.0 - 1.0, 1.0 - screen_uv.y * 2.0);
}
struct HandMembraneVertexOutput {
@builtin(position) clip_position: vec4f,
@location(0) local: vec2f,
}
fn rotate_2d(point: vec2f, angle: f32) -> vec2f {
let c = cos(angle);
let s = sin(angle);
return vec2f(point.x * c - point.y * s, point.x * s + point.y * c);
}
fn rounded_rect_alpha(local: vec2f, radius: f32, softness: f32) -> f32 {
let q = abs(local) - vec2f(1.0 - radius, 1.0 - radius);
let dist = length(max(q, vec2f(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - radius;
return 1.0 - smoothstep(0.0, softness, dist);
}
fn fingertip_film_alpha(
local: vec2f,
half_width: f32,
cap_center_y: f32,
rear_edge_y: f32,
rear_bulge: f32,
softness: f32,
) -> f32 {
// Top/front of the film is a closed round fingertip cap.
let cap_dist = length(vec2f(local.x, local.y - cap_center_y));
let cap = (1.0 - smoothstep(half_width, half_width + softness, cap_dist))
* (1.0 - smoothstep(cap_center_y - softness, cap_center_y + softness, local.y));
// The rear half keeps nearly parallel sides and ends with a shallow arc;
// it deliberately does not converge back into another capsule end.
let x_norm = clamp(abs(local.x) / max(half_width, 0.001), 0.0, 1.0);
let rear_curve_y = rear_edge_y + rear_bulge * (1.0 - x_norm * x_norm);
let side = 1.0 - smoothstep(half_width, half_width + softness, abs(local.x));
let rear = 1.0 - smoothstep(rear_curve_y, rear_curve_y + softness, local.y);
let body_gate = smoothstep(cap_center_y - softness, cap_center_y + softness, local.y);
let body = side * rear * body_gate;
return max(cap, body);
}
@vertex
fn vs_hand_membrane(vertex: DotVertexInput, instance: DotInstanceInput) -> HandMembraneVertexOutput {
let center_px = instance.world_position.xy * u.image.xy;
let size_px = instance.style.yz;
let angle = instance.style.x;
let local_px = vertex.local * size_px * 0.5;
let image_uv = (center_px + rotate_2d(local_px, angle)) / max(u.image.xy, vec2f(1.0, 1.0));
var out: HandMembraneVertexOutput;
out.clip_position = vec4f(sensor_canvas_uv_to_clip(image_uv), 0.0, 1.0);
out.local = vertex.local;
return out;
}
@fragment
fn fs_hand_membrane(in: HandMembraneVertexOutput) -> @location(0) vec4f {
// The film shape matches the fingertip: closed round front, parallel rear sides,
// and a shallow rear arc instead of a second capsule end.
let panel = fingertip_film_alpha(in.local, 0.66, -0.38, 0.72, 0.18, 0.045);
let inner = fingertip_film_alpha(in.local, 0.54, -0.36, 0.64, 0.12, 0.060);
let border = clamp(panel - inner * 0.52, 0.0, 1.0);
// Dense mesh: the live 12x7 pressure dots sit over this finer sensor lattice.
let uv = clamp(in.local * 0.5 + vec2f(0.5, 0.5), vec2f(0.0, 0.0), vec2f(1.0, 1.0));
let grid = vec2f(18.0, 34.0);
let cell = uv * grid - vec2f(0.5, 0.5);
let nearest = abs(fract(cell + vec2f(0.5, 0.5)) - vec2f(0.5, 0.5));
let grid_line = max(
1.0 - smoothstep(0.014, 0.038, nearest.x),
1.0 - smoothstep(0.014, 0.038, nearest.y),
);
let joint = 1.0 - smoothstep(0.070, 0.150, length(nearest * vec2f(1.18, 1.0)));
let center_shadow = 1.0 - smoothstep(0.10, 0.76, length(in.local * vec2f(0.92, 0.66)));
let top_light = smoothstep(-0.52, 0.16, -in.local.y) * 0.20;
let side_glow = smoothstep(0.30, 0.70, abs(in.local.x)) * 0.26;
let lift_shadow = smoothstep(0.48, 0.86, in.local.y) * (1.0 - smoothstep(0.50, 0.86, abs(in.local.x))) * 0.13;
let scan = (0.5 + 0.5 * sin((uv.y * 76.0 + uv.x * 9.0) * 6.28318)) * 0.030;
let membrane_color = vec3f(0.006, 0.28, 0.38);
let line_color = vec3f(0.12, 0.72, 0.88);
let rim_color = vec3f(0.18, 0.98, 1.0);
let color = membrane_color * (0.68 + top_light + side_glow + scan)
+ line_color * (grid_line * 0.20 + joint * 0.42)
+ rim_color * (border * 0.92 + side_glow * 0.18)
- vec3f(0.0, 0.16, 0.24) * center_shadow * 0.30
- vec3f(0.0, 0.10, 0.16) * lift_shadow;
let alpha = panel * (0.24 + grid_line * 0.10 + joint * 0.23 + border * 0.42);
return output_color(color, alpha);
}
@vertex
fn vs_hand_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
let intensity = saturate(instance.style.x);
@@ -384,10 +468,10 @@ fn vs_hand_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexO
let shaped = smoothstep(0.0, 1.0, intensity);
// Hand instances store hand.png UV in world_position.xy instead of 3D world space.
let center = hand_image_uv_to_clip(instance.world_position.xy);
let center = sensor_canvas_uv_to_clip(instance.world_position.xy);
// Hand fingertip matrices are much smaller than the full Finger view.
// Keep each bead below the local cell spacing so the 12x7 matrix remains visibly separated.
let pixel_size = u.glyph.x * mix(0.38, 0.56, shaped);
let pixel_size = u.glyph.x * mix(0.22, 0.34, shaped);
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
var out: DotVertexOutput;
@@ -401,19 +485,47 @@ fn vs_hand_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexO
fn fs_hand_dot(in: DotVertexOutput) -> @location(0) vec4f {
let intensity = saturate(in.intensity);
// Use a compact round bead instead of the larger Finger-mode glow marker.
let core = circle_alpha(in.local, 0.56, 0.08);
let halo = circle_alpha(in.local, 0.78, 0.14) * 0.16;
// Use a compact bead so the response feels like it lives on the membrane mesh.
let core = circle_alpha(in.local, 0.48, 0.07);
let halo = circle_alpha(in.local, 0.74, 0.14) * (0.10 + intensity * 0.26);
// Keep the fingertip matrix visually close to JE-Skin's cyan model dots,
// while still letting pressure brighten the bead a little.
let cyan = vec3f(0.34, 0.86, 1.0);
let hot = sample_range_color(intensity);
let color = mix(cyan, hot, 0.22) * mix(0.88, 1.18, intensity);
// Match Finger mode's pressure gradient so each hand region remains readable.
let idle = vec3f(0.060, 0.250, 0.320);
let gradient = sample_range_color(intensity);
let color = mix(idle, gradient, smoothstep(0.0, 0.18, intensity))
* mix(0.78, 1.18, intensity);
return output_color(color, max(core, halo));
}
@vertex
fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
let intensity = saturate(instance.style.x);
let shaped = smoothstep(0.0, 1.0, intensity);
// Use the same on-screen point size as Finger mode.
let center = sensor_canvas_uv_to_clip(instance.world_position.xy);
let pixel_size = u.glyph.x * mix(1.07, 2.23, shaped);
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
var out: DotVertexOutput;
out.clip_position = vec4f(center + ndc_offset, 0.0, 1.0);
out.local = vertex.local;
out.intensity = intensity;
return out;
}
@fragment
fn fs_hand_palm_dot(in: DotVertexOutput) -> @location(0) vec4f {
let intensity = saturate(in.intensity);
let base_color = sample_range_color(intensity);
let alpha = circle_alpha(in.local, 0.46, 0.045);
let color = base_color * mix(0.86, 1.06, intensity);
return output_color(color, alpha);
}
@vertex
fn vs_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0);
@@ -435,19 +547,8 @@ fn fs_dot(in: DotVertexOutput) -> @location(0) vec4f {
let intensity = saturate(in.intensity);
let base_color = sample_range_color(intensity);
let core = circle_alpha(in.local, 0.42, 0.055);
let glow = circle_alpha(in.local, 0.92, 0.20) * intensity * 0.32;
let highlight_pos = in.local - vec2f(-0.18, 0.20);
let highlight = circle_alpha(highlight_pos, 0.16, 0.08) * 0.45;
let brightness = mix(0.82, 1.22, intensity);
let color = base_color * brightness + vec3f(1.0, 1.0, 1.0) * highlight;
let alpha = max(core, glow);
let alpha = circle_alpha(in.local, 0.46, 0.045);
let color = base_color * mix(0.86, 1.06, intensity);
return output_color(color, alpha);
}

254
wix/main.wxs Normal file
View File

@@ -0,0 +1,254 @@
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
The "cargo wix" subcommand provides a variety of predefined variables available
for customization of this template. The values for each variable are set at
installer creation time. The following variables are available:
TargetTriple = The rustc target triple name.
TargetEnv = The rustc target environment. This is typically either
"msvc" or "gnu" depending on the toolchain downloaded and
installed.
TargetVendor = The rustc target vendor. This is typically "pc", but Rust
does support other vendors, like "uwp".
CargoTargetBinDir = The complete path to the directory containing the
binaries (exes) to include. The default would be
"target\release\". If an explicit rustc target triple is
used, i.e. cross-compiling, then the default path would
be "target\<CARGO_TARGET>\<CARGO_PROFILE>",
where "<CARGO_TARGET>" is replaced with the "CargoTarget"
variable value and "<CARGO_PROFILE>" is replaced with the
value from the "CargoProfile" variable. This can also
be overridden manually with the "target-bin-dir" flag.
CargoTargetDir = The path to the directory for the build artifacts, i.e.
"target".
CargoProfile = The cargo profile used to build the binaries
(usually "debug" or "release").
Version = The version for the installer. The default is the
"Major.Minor.Fix" semantic versioning number of the Rust
package.
-->
<!--
Please do not remove these pre-processor If-Else blocks. These are used with
the `cargo wix` subcommand to automatically determine the installation
destination for 32-bit versus 64-bit installers. Removal of these lines will
cause installation errors.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Product
Id='*'
Name='eskin-model-player'
UpgradeCode='7CEF316B-BE23-4533-B4B2-87D06D2230B5'
Manufacturer='JOYSONQUIN'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='Desktop pressure sensor visualization and playback application.'
Manufacturer='JOYSONQUIN'
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perUser'
InstallPrivileges='limited'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='eskin-model-player Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='LocalAppDataFolder'>
<Directory Id='APPLICATIONFOLDER' Name='eskin-model-player'>
<!--
Enabling the license sidecar file in the installer is a four step process:
1. Uncomment the `Component` tag and its contents.
2. Change the value for the `Source` attribute in the `File` tag to a path
to the file that should be included as the license sidecar file. The path
can, and probably should be, relative to this file.
3. Change the value for the `Name` attribute in the `File` tag to the
desired name for the file when it is installed alongside the `bin` folder
in the installation directory. This can be omitted if the desired name is
the same as the file name.
4. Uncomment the `ComponentRef` tag with the Id attribute value of "License"
further down in this file.
-->
<!--
<Component Id='License' Guid='*'>
<File Id='LicenseFile' Name='ChangeMe' DiskId='1' Source='C:\Path\To\File' KeyPath='yes'/>
</Component>
-->
<Directory Id='Bin' Name='bin'>
<Component Id='Path' Guid='FD230ABD-75B9-47D4-870B-CF2EA72B76BE'>
<RegistryValue
Root='HKCU'
Key='Software\JOYSONQUIN\eskin-model-player'
Name='PathComponent'
Type='integer'
Value='1'
KeyPath='yes'/>
<RemoveFolder Id='RemoveBinFolder' Directory='Bin' On='uninstall'/>
<RemoveFolder Id='RemoveApplicationFolder' Directory='APPLICATIONFOLDER' On='uninstall'/>
<Environment
Id='PATH'
Name='PATH'
Value='[Bin]'
Permanent='no'
Part='last'
Action='set'
System='no'/>
</Component>
<Component Id='binary0' Guid='302CAFB5-6951-426B-BC5A-988C351A2CF2'>
<RegistryValue
Root='HKCU'
Key='Software\JOYSONQUIN\eskin-model-player'
Name='BinaryComponent'
Type='integer'
Value='1'
KeyPath='yes'/>
<File
Id='exe0'
Name='ESkinPlayer.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\ESkinPlayer.exe'/>
</Component>
</Directory>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs all binaries and the license.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<!--
Uncomment the following `ComponentRef` tag to add the license
sidecar file to the installer.
-->
<!--<ComponentRef Id='License'/>-->
<ComponentRef Id='binary0'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<!--
Uncomment the following `Icon` and `Property` tags to change the product icon.
The product icon is the graphic that appears in the Add/Remove
Programs control panel for the application.
-->
<!--<Icon Id='ProductICO' SourceFile='wix\Product.ico'/>-->
<!--<Property Id='ARPPRODUCTICON' Value='ProductICO' />-->
<!--
Adding a URL to Add/Remove Programs control panel listing for the
application is a two step process:
1. Uncomment the following `Property` tag with the "ARPHELPLINK" Id
attribute value.
2. Change the value for `Value` attribute of the following
`Property` tag to a valid URL.
-->
<!--<Property Id='ARPHELPLINK' Value='ChangeMe'/>-->
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<!--
Enabling the EULA dialog in the installer is a three step process:
1. Comment out or remove the two `Publish` tags that follow the
`WixVariable` tag.
2. Uncomment the `<WixVariable Id='WixUILicenseRtf' Value='Path\to\Eula.rft'>` tag further down
3. Replace the `Value` attribute of the `WixVariable` tag with
the path to a RTF file that will be used as the EULA and
displayed in the license agreement dialog.
-->
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
</UI>
<!--
Enabling the EULA dialog in the installer requires uncommenting
the following `WixUILicenseRTF` tag and changing the `Value`
attribute.
-->
<!-- <WixVariable Id='WixUILicenseRtf' Value='Relative\Path\to\Eula.rtf'/> -->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom banner image across
the top of each screen. See the WiX Toolset documentation for details
about customization.
The banner BMP dimensions are 493 x 58 pixels.
-->
<!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom image to the first
dialog, or screen. See the WiX Toolset documentation for details about
customization.
The dialog BMP dimensions are 493 x 312 pixels.
-->
<!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
</Product>
</Wix>