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