From d93a6694cfff135dcfb83336abdf2c8e4fb7a819 Mon Sep 17 00:00:00 2001 From: lenn Date: Mon, 27 Jul 2026 17:25:30 +0800 Subject: [PATCH] fix: correct 3D fingertip sampling and PCB mapping --- src/app.rs | 45 ++- src/matrix.rs | 26 ++ src/render.rs | 528 +++++++++++++++++++--------- src/serial_core/codecs/tactile_a.rs | 58 +++ src/ui.rs | 54 ++- static/wgsl/shader.wgsl | 88 +---- 6 files changed, 527 insertions(+), 272 deletions(-) diff --git a/src/app.rs b/src/app.rs index c5abde0..a3c2e3b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ use crate::render::{ActiveMode, FingerMode, HandGatewayMode}; 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, PressureSamples, WgpuBackgroundCallback, @@ -21,8 +21,8 @@ use eframe::{egui, egui_wgpu}; use std::sync::Arc; const SUMMARY_POINTS_PER_SERIES: usize = 42; -const HAND_FORCE_PANEL_COUNT: usize = 7; -const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44]; +const HAND_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, @@ -218,6 +218,12 @@ impl EskinDesktopApp { 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, @@ -244,10 +250,6 @@ impl EskinDesktopApp { if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES { self.signal_history.remove(0); } - - if self.config_state.mode == SerialMode::Hand { - update_hand_signal_histories(&mut self.hand_signal_histories, &sample.matrix); - } } } @@ -395,6 +397,14 @@ impl EskinDesktopApp { 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); } @@ -498,6 +508,15 @@ impl EskinDesktopApp { } 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(); @@ -518,8 +537,10 @@ 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 }) + } + }; } } @@ -675,12 +696,6 @@ fn draw_config_bar_mode(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Opt if mode_button(ui, &mut config.mode, SerialMode::Finger, "单面指尖") { changed_to = Some(SerialMode::Finger); } - // if mode_button(ui, &mut config.mode, SerialMode::Finger3D, "3D指尖") { - // changed_to = Some(SerialMode::Finger3D); - // } - if mode_button(ui, &mut config.mode, SerialMode::Hand, "展示手掌") { - changed_to = Some(SerialMode::Hand); - } }); changed_to diff --git a/src/matrix.rs b/src/matrix.rs index 03650fa..578c9c4 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -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::>(); + + assert_eq!(first_h0_scan, (0..8).collect::>()); + 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; diff --git a/src/render.rs b/src/render.rs index 9d25a2a..9ebc527 100644 --- a/src/render.rs +++ b/src/render.rs @@ -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, }; @@ -70,26 +73,96 @@ const HAND_TIP_MATRICES: [HandTipMatrix; 5] = [ }, ]; -const HAND_PALM_CHIPS: [HandPalmChip; 2] = [ +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: [538.0, 608.0], - size_px: [248.0, 82.0], - angle_rad: 0.06, - rows: 5, - cols: 14, + 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: [606.0, 780.0], - size_px: [72.0, 214.0], - angle_rad: 0.05, + 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; -const HAND_PALM_HORIZONTAL_OFFSET: usize = HAND_FINGER_SENSOR_CELLS * 5; -const HAND_PALM_VERTICAL_OFFSET: usize = HAND_PALM_HORIZONTAL_OFFSET + 5 * 14; // 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. @@ -100,14 +173,15 @@ 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. +// 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 { @@ -156,7 +230,6 @@ 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, @@ -168,8 +241,6 @@ 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, @@ -277,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"), @@ -497,8 +565,6 @@ 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); @@ -551,23 +617,8 @@ 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_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"), @@ -589,7 +640,6 @@ 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, @@ -600,8 +650,6 @@ 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, @@ -623,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, @@ -653,8 +698,8 @@ impl BackgroundRenderResources { hand_pressure }; - // Hand mode uses UV-anchored fingertip matrices over hand.png. - // Rebuild their instance positions here so pressure colors update every frame. + // 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, @@ -668,15 +713,9 @@ impl BackgroundRenderResources { 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, - hand_pressure, - ); + // 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, @@ -692,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), } } @@ -719,22 +753,7 @@ impl BackgroundRenderResources { fn paint_hand(&self, render_pass: &mut wgpu::RenderPass<'_>, mode: &HandGatewayMode) { let _range = mode.range.clone(); - // First draw the translucent sensor membranes, then draw live pressure beads on their grid. - render_pass.set_pipeline(&self.hand_membrane_pipeline); - render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..)); - render_pass.set_vertex_buffer(1, self.hand_membrane_instance_buffer.slice(..)); - render_pass.draw(0..6, 0..self.hand_membrane_instances.len() as u32); - - render_pass.set_pipeline(&self.hand_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_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); - + // 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_palm_dot_instance_buffer.slice(..)); @@ -1072,39 +1091,6 @@ 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, @@ -1156,28 +1142,6 @@ 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, @@ -1225,44 +1189,26 @@ fn build_hand_dot_instances( fn build_hand_palm_dot_instances( _rows: u32, _cols: u32, - image_width: f32, - image_height: f32, pressure: &[[f32; 2]], ) -> Vec { - let chip_dot_count: usize = HAND_PALM_CHIPS + 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 HAND_PALM_CHIPS.into_iter().enumerate() { - 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 (chip_index, chip) in UNFOLDED_SENSOR_CHIPS.iter().enumerate() { for row in 0..chip.rows { for col in 0..chip.cols { - let index = (row * chip.cols + col) as usize; - let offset = match chip_index { - 0 => HAND_PALM_HORIZONTAL_OFFSET, - _ => HAND_PALM_VERTICAL_OFFSET, - }; - let [normalized, display_value] = - sample_pressure_at(pressure, offset + index, index); - - 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; + 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 / image_width.max(1.0), - y / image_height.max(1.0), + x / UNFOLDED_CANVAS_SIZE[0], + y / UNFOLDED_CANVAS_SIZE[1], 0.0, 1.0, ], @@ -1275,6 +1221,47 @@ fn build_hand_palm_dot_instances( 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) @@ -1283,6 +1270,225 @@ fn sample_pressure_at(pressure: &[[f32; 2]], index: usize, fallback_index: usize .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![81, 73, 65, 57, 49, 41, 33, 25] + ); + assert_eq!( + (0..10) + .map(|row| unfolded_adc_sample_index(9, row, 0) + 1) + .collect::>(), + 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![7, 15, 23] + ); + assert_eq!( + (0..5) + .map(|row| unfolded_adc_sample_index(2, row, 0) + 1) + .collect::>(), + vec![104, 96, 8, 16, 24] + ); + assert_eq!( + (0..8) + .map(|row| unfolded_adc_sample_index(3, row, 0) + 1) + .collect::>(), + 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![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::>(); + let rendered = (0..4) + .map(|col| unfolded_adc_sample_index(5, row, col) + 1) + .collect::>(); + 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::>(); + 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, diff --git a/src/serial_core/codecs/tactile_a.rs b/src/serial_core/codecs/tactile_a.rs index 11ad808..8af5132 100644 --- a/src/serial_core/codecs/tactile_a.rs +++ b/src/serial_core/codecs/tactile_a.rs @@ -188,3 +188,61 @@ impl Codec 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::>(); + 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 + ); + } +} diff --git a/src/ui.rs b/src/ui.rs index 432e334..df4017c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -53,7 +53,7 @@ pub struct ConnectPanelState { #[derive(Clone, Copy, PartialEq, Eq)] pub enum SerialMode { Finger, - // Finger3D, + Finger3D, Hand, } @@ -61,16 +61,26 @@ impl SerialMode { pub fn baud_rate(self) -> u32 { match self { SerialMode::Finger => 921_600, - SerialMode::Hand => 1_152_000, + SerialMode::Finger3D => 921_600, + SerialMode::Hand => 921_600, } } pub fn protocol(self) -> SerialProtocol { match self { SerialMode::Finger => SerialProtocol::TactileA, + SerialMode::Finger3D => SerialProtocol::TactileA, SerialMode::Hand => SerialProtocol::HandGateway, } } + + pub fn matrix_shape(self) -> (u32, u32) { + match self { + SerialMode::Finger => (12, 7), + SerialMode::Finger3D => (12, 9), + SerialMode::Hand => (12, 7), + } + } } #[derive(Clone, Copy, PartialEq, Eq)] @@ -483,11 +493,11 @@ fn draw_config_bar_mode(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Opt if mode_button(ui, &mut config.mode, SerialMode::Finger, "指尖模块") { changed_to = Some(SerialMode::Finger) } - if mode_button(ui, &mut config.mode, SerialMode::Hand, "手掌模块") { - changed_to = Some(SerialMode::Hand) - } // mode_button(ui, &mut config.mode, SerialMode::Model, "模型"); + if mode_button(ui, &mut config.mode, SerialMode::Finger3D, "3D指尖") { + changed_to = Some(SerialMode::Finger3D) + } // Legacy reconnect controls. Current sensors run fixed 921600 baud without auto-reconnect UI. // ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // ui.checkbox(&mut config.auto_reconnect, "自动"); @@ -566,10 +576,11 @@ fn draw_config_bar_connection( if is_connected { connection.disconnect(); } else if !config.port.is_empty() { + let (rows, cols) = config.mode.matrix_shape(); connection.connect( &config.port, - 12, - 7, + rows, + cols, config.mode.baud_rate(), config.mode.protocol(), recorder.clone(), @@ -637,10 +648,11 @@ fn draw_connection_row( if is_connected { connection.disconnect(); } else if !config.port.is_empty() { + let (rows, cols) = config.mode.matrix_shape(); connection.connect( &config.port, - 12, - 7, + rows, + cols, config.mode.baud_rate(), config.mode.protocol(), recorder.clone(), @@ -758,6 +770,9 @@ fn draw_mode_body( // }); draw_status_bytes_row(ui, conn_state, stats); } + SerialMode::Finger3D => { + draw_status_bytes_row(ui, conn_state, stats); + } SerialMode::Hand => { // Legacy manual-TX controls for older hand-module debugging: // ui.horizontal(|ui| { @@ -969,14 +984,17 @@ const HAND_FORCE_PANEL_MAX_HEIGHT: f32 = 190.0; const HAND_FORCE_PANEL_GAP: f32 = 12.0; const HAND_FORCE_PANEL_SIDE_MARGIN: f32 = 24.0; const HAND_FORCE_PANEL_VERTICAL_MARGIN: f32 = 28.0; -const HAND_FORCE_PANEL_TITLES: [(&str, &str); 7] = [ - ("T1", "拇指"), - ("T2", "食指"), - ("T3", "中指"), - ("T4", "无名指"), - ("T5", "小指"), - ("P1", "掌心横区"), - ("P2", "掌心纵区"), +const HAND_FORCE_PANEL_TITLES: [(&str, &str); 10] = [ + ("T", "顶部 2×4"), + ("L3", "左侧 3×1"), + ("L5", "左侧 5×1"), + ("L8", "左侧 8×1"), + ("L10", "左侧 10×1"), + ("C", "中央 11×4"), + ("R3", "右侧 3×1"), + ("R5", "右侧 5×1"), + ("R8", "右侧 8×1"), + ("R10", "右侧 10×1"), ]; fn has_recent_resultant_force(values: &[f32]) -> bool { @@ -1002,7 +1020,7 @@ pub fn draw_hand_force_panels(ctx: &egui::Context, visible: bool, histories: &[V .max(HAND_FORCE_PANEL_MIN_WIDTH.min(side_width)); let left_x = screen.left() + side_margin; let right_x = screen.right() - side_margin - panel_width; - let left_count = 4usize; + let left_count = 5usize; let right_count = HAND_FORCE_PANEL_TITLES.len() - left_count; let available_height = (screen.height() - layout::TITLE_BAR_HEIGHT - vertical_margin * 2.0).max(0.0); diff --git a/static/wgsl/shader.wgsl b/static/wgsl/shader.wgsl index 1797361..1a95df0 100644 --- a/static/wgsl/shader.wgsl +++ b/static/wgsl/shader.wgsl @@ -349,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); @@ -420,7 +418,7 @@ fn vs_hand_membrane(vertex: DotVertexInput, instance: DotInstanceInput) -> HandM 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(hand_image_uv_to_clip(image_uv), 0.0, 1.0); + out.clip_position = vec4f(sensor_canvas_uv_to_clip(image_uv), 0.0, 1.0); out.local = vertex.local; return out; } @@ -470,7 +468,7 @@ 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.22, 0.34, shaped); @@ -500,75 +498,14 @@ 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); + // 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; @@ -581,16 +518,11 @@ fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVe @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.36; + let base_color = sample_range_color(intensity); - let cold = vec3f(0.070, 0.340, 0.360); - let gradient = sample_range_color(intensity); - let color = mix(cold, gradient, smoothstep(0.0, 0.20, intensity)) - * (0.58 + intensity * 1.04) - + gradient * glow * 0.72; + let alpha = circle_alpha(in.local, 0.46, 0.045); + let color = base_color * mix(0.86, 1.06, intensity); - let alpha = max(pixel * (0.20 + intensity * 0.76), glow); return output_color(color, alpha); }