2 Commits

Author SHA1 Message Date
lenn
91307f1985 refactor(serial): 新增 hand_gateway 编解码模块,修正模块名拼写 2026-06-22 17:44:07 +08:00
lenn
b343e74a12 mpush 2026-06-22 11:18:11 +08:00
13 changed files with 853 additions and 211 deletions

View File

@@ -1,3 +1,4 @@
use crate::serial_core::codecs::hand_gateway::HandGatewayCodec;
use crate::serial_core::codecs::tactile_a::{ use crate::serial_core::codecs::tactile_a::{
export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler, export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler,
}; };
@@ -6,7 +7,7 @@ use crate::serial_core::record::CsvImporter;
use crate::serial_core::serial::{PollMode, TactileAPollRequester}; use crate::serial_core::serial::{PollMode, TactileAPollRequester};
use crate::serial_core::{serial, TactileARecording}; use crate::serial_core::{serial, TactileARecording};
use log::info; use log::info;
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::fs::File; use std::fs::File;
use std::io::Cursor; use std::io::Cursor;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -21,6 +22,10 @@ const DEFAULT_TACTILE_ROWS: usize = 12;
const DEFAULT_TACTILE_POLL_INTERVAL_MS: u64 = 10; const DEFAULT_TACTILE_POLL_INTERVAL_MS: u64 = 10;
const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140; const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140;
// MCU currently uses a fixed node layout. Update this list to match the firmware:
// one entry per Config bit, containing that node's number of u16 samples.
const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84];
type SharedTactileRecording = Arc<Mutex<TactileARecording>>; type SharedTactileRecording = Arc<Mutex<TactileARecording>>;
#[derive(Serialize)] #[derive(Serialize)]
@@ -31,6 +36,13 @@ pub struct SerialConnectResponse {
pub message: String, pub message: String,
} }
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SerialProtocol {
TactileA,
HandGateway,
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SerialExportResponse { pub struct SerialExportResponse {
@@ -67,7 +79,7 @@ struct SerialSession {
port: String, port: String,
cancel: CancellationToken, cancel: CancellationToken,
task: JoinHandle<()>, task: JoinHandle<()>,
current_record: SharedTactileRecording, current_record: Option<SharedTactileRecording>,
} }
#[derive(Default)] #[derive(Default)]
@@ -78,7 +90,7 @@ pub struct SerialConnectionState {
pub async fn shutdown_active_session( pub async fn shutdown_active_session(
state: &SerialConnectionState, state: &SerialConnectionState,
) -> Result<Option<(String, SharedTactileRecording)>, SerialError> { ) -> Result<Option<(String, Option<SharedTactileRecording>)>, SerialError> {
let session = { let session = {
let mut guard = state.session.lock().map_err(|_| SerialError::StateError)?; let mut guard = state.session.lock().map_err(|_| SerialError::StateError)?;
guard.take() guard.take()
@@ -98,13 +110,15 @@ pub async fn shutdown_active_session(
let _ = task.await; let _ = task.await;
let frame_count = current_record let frame_count = current_record
.lock() .as_ref()
.map(|record| record.frames.len()) .and_then(|record| record.lock().ok().map(|record| record.frames.len()))
.unwrap_or(0); .unwrap_or(0);
info!("last_record has {} frames", frame_count); info!("last_record has {} frames", frame_count);
if let Ok(mut last_record) = state.last_record.lock() { if let (Some(current_record), Ok(mut last_record)) =
(current_record.as_ref(), state.last_record.lock())
{
*last_record = Some(current_record.clone()); *last_record = Some(current_record.clone());
} }
@@ -133,6 +147,7 @@ pub fn serial_enum() -> Result<Vec<String>, SerialError> {
pub async fn serial_connect( pub async fn serial_connect(
app: AppHandle, app: AppHandle,
port: String, port: String,
protocol: SerialProtocol,
state: State<'_, SerialConnectionState>, state: State<'_, SerialConnectionState>,
) -> Result<SerialConnectResponse, SerialError> { ) -> Result<SerialConnectResponse, SerialError> {
let port_name = port.trim().to_string(); let port_name = port.trim().to_string();
@@ -148,7 +163,10 @@ pub async fn serial_connect(
} }
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
let current_record = Arc::new(Mutex::new(TactileARecording::new())); let current_record = match protocol {
SerialProtocol::TactileA => Some(Arc::new(Mutex::new(TactileARecording::new()))),
SerialProtocol::HandGateway => None,
};
let task_record = current_record.clone(); let task_record = current_record.clone();
let task_cancel = cancel.clone(); let task_cancel = cancel.clone();
let task_app = app.clone(); let task_app = app.clone();
@@ -160,32 +178,51 @@ pub async fn serial_connect(
let session_started_at = Instant::now(); let session_started_at = Instant::now();
let task = tauri::async_runtime::spawn(async move { let task = tauri::async_runtime::spawn(async move {
let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS); let run_result = match protocol {
let handler = TactileAHandler; SerialProtocol::TactileA => {
let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new( let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS);
Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS), let handler = TactileAHandler;
DEFAULT_TACTILE_COLS, let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new(
DEFAULT_TACTILE_ROWS, Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS),
Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS), DEFAULT_TACTILE_COLS,
))); DEFAULT_TACTILE_ROWS,
Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS),
)));
if let Err(error) = serial::run_serial_with_poll( serial::run_serial_with_poll(
task_app.clone(), task_app.clone(),
port, port,
codec, codec,
handler, handler,
session_started_at, session_started_at,
task_record.clone(), task_record
task_cancel, .clone()
poll_mode, .expect("tactile protocol must have a recording"),
) task_cancel,
.await poll_mode,
{ )
.await
}
SerialProtocol::HandGateway => {
let codec = HandGatewayCodec::new(DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS);
serial::run_hand_gateway_serial(
task_app.clone(),
port,
codec,
session_started_at,
task_cancel,
)
.await
}
};
if let Err(error) = run_result {
eprintln!("serial task exited with error: {error}"); eprintln!("serial task exited with error: {error}");
} }
let manager = task_app.state::<SerialConnectionState>(); let manager = task_app.state::<SerialConnectionState>();
if let Ok(mut last_record) = manager.last_record.lock() { if let (Some(task_record), Ok(mut last_record)) = (task_record, manager.last_record.lock())
{
*last_record = Some(task_record); *last_record = Some(task_record);
} }
@@ -360,7 +397,7 @@ fn resolve_record_for_export(
let session = state.session.lock().map_err(|_| SerialError::StateError)?; let session = state.session.lock().map_err(|_| SerialError::StateError)?;
session session
.as_ref() .as_ref()
.map(|current_session| current_session.current_record.clone()) .and_then(|current_session| current_session.current_record.clone())
}; };
if let Some(recording) = current_record { if let Some(recording) = current_record {
@@ -381,7 +418,7 @@ fn snapshot_record_frame_count(
let session = state.session.lock().map_err(|_| SerialError::StateError)?; let session = state.session.lock().map_err(|_| SerialError::StateError)?;
session session
.as_ref() .as_ref()
.map(|current_session| current_session.current_record.clone()) .and_then(|current_session| current_session.current_record.clone())
}; };
if let Some(record) = current_record { if let Some(record) = current_record {

View File

@@ -0,0 +1,390 @@
use crate::serial_core::codec::Codec;
use crate::serial_core::error::CodecError;
use crate::serial_core::frame::{
HandGatewayDataRepFrame, HandGatewayFrame, HandGatewayFrameMetaData, HandGatewayFrameNode,
};
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
use log::debug;
use std::time::Instant;
const FRAME_HEADER: [u8; 2] = [0x55, 0xAA];
const RESPONSE_DATA_CMD: u8 = 0x81;
const RESPONSE_DATA_FIXED_LENGTH: usize = 16;
const RESPONSE_DATA_PREFIX_LENGTH: usize = 19;
const MIN_FRAME_LENGTH: usize = 7;
const DEFAULT_MAX_FRAME_LENGTH: usize = 64 * 1024;
/// Describes one fixed MCU data block.
///
/// `config_mask` identifies the bit associated with this block in the protocol's
/// Config/ValidConfig fields. `sample_count` is the number of little-endian u16
/// samples in the block.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayNodeConfig {
pub config_mask: u32,
pub sample_count: usize,
}
impl HandGatewayNodeConfig {
pub const fn new(config_mask: u32, sample_count: usize) -> Self {
Self {
config_mask,
sample_count,
}
}
fn payload_len(&self, bytes_per_sample: usize) -> Option<usize> {
self.sample_count.checked_mul(bytes_per_sample)
}
}
/// Host-side description of the configuration currently hard-coded in the MCU.
///
/// Keep the node order identical to the MCU Config bit order. When the MCU gains
/// GET_CONFIG support, construct this value from that response instead of changing
/// the decoder.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayConfig {
pub protocol_version: u8,
pub bytes_per_sample: usize,
pub max_frame_length: usize,
pub nodes: Vec<HandGatewayNodeConfig>,
}
impl HandGatewayConfig {
pub fn new(nodes: Vec<HandGatewayNodeConfig>) -> Self {
Self {
protocol_version: 0x01,
bytes_per_sample: 2,
max_frame_length: DEFAULT_MAX_FRAME_LENGTH,
nodes,
}
}
fn validate(&self) -> Result<(), CodecError> {
if self.bytes_per_sample == 0 || self.max_frame_length < MIN_FRAME_LENGTH {
return Err(CodecError::InvalidLength);
}
let mut used_masks = 0u32;
for node in &self.nodes {
if node.config_mask == 0
|| node.config_mask.count_ones() != 1
|| used_masks & node.config_mask != 0
|| node.payload_len(self.bytes_per_sample).is_none()
{
return Err(CodecError::InvalidLength);
}
used_masks |= node.config_mask;
}
Ok(())
}
}
pub struct HandGatewayCodec {
buffer: Vec<u8>,
config: HandGatewayConfig,
}
impl HandGatewayCodec {
/// Convenience constructor for sequential Config bits (bit 0, bit 1, ...).
/// Each entry is the u16 sample count of one MCU node.
pub fn new(node_sample_counts: &[u16]) -> Self {
let nodes = node_sample_counts
.iter()
.enumerate()
.map(|(index, &sample_count)| {
let config_mask = 1u32.checked_shl(index as u32).unwrap_or(0);
HandGatewayNodeConfig::new(config_mask, usize::from(sample_count))
})
.collect();
Self {
buffer: Vec::new(),
config: HandGatewayConfig::new(nodes),
}
}
pub fn with_config(config: HandGatewayConfig) -> Result<Self, CodecError> {
config.validate()?;
Ok(Self {
buffer: Vec::new(),
config,
})
}
pub fn config(&self) -> &HandGatewayConfig {
&self.config
}
pub fn parse_node_payload(data: &[u8]) -> Result<Vec<u16>, CodecError> {
if data.len() % 2 != 0 {
return Err(CodecError::InvalidLength);
}
Ok(data
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect())
}
fn retain_possible_header_prefix(&mut self) {
if self.buffer.last() == Some(&FRAME_HEADER[0]) {
self.buffer.drain(..self.buffer.len() - 1);
} else {
self.buffer.clear();
}
}
fn parse_data_response(
&self,
frame: &[u8],
length: u16,
session_started_at: Instant,
) -> Result<HandGatewayFrame, CodecError> {
let payload_len = usize::from(length)
.checked_sub(RESPONSE_DATA_FIXED_LENGTH)
.ok_or(CodecError::InvalidLength)?;
let payload_end = RESPONSE_DATA_PREFIX_LENGTH
.checked_add(payload_len)
.ok_or(CodecError::PayloadTooLarge)?;
if payload_end + 1 != frame.len() {
return Err(CodecError::InvalidLength);
}
let version = frame[5];
if version != self.config.protocol_version {
return Err(CodecError::InvalidFrameType);
}
let timestamp = u32::from_le_bytes(frame[6..10].try_into().unwrap());
let config = u32::from_le_bytes(frame[10..14].try_into().unwrap());
let valid_config = u32::from_le_bytes(frame[14..18].try_into().unwrap());
let block_count = frame[18];
let active_nodes: Vec<&HandGatewayNodeConfig> = self
.config
.nodes
.iter()
.filter(|node| config & node.config_mask != 0)
.collect();
if active_nodes.len() != usize::from(block_count) {
return Err(CodecError::InvalidLength);
}
let expected_payload_len = active_nodes.iter().try_fold(0usize, |total, node| {
let node_len = node
.payload_len(self.config.bytes_per_sample)
.ok_or(CodecError::PayloadTooLarge)?;
total
.checked_add(node_len)
.ok_or(CodecError::PayloadTooLarge)
})?;
if expected_payload_len != payload_len {
return Err(CodecError::InvalidLength);
}
let mut cursor = RESPONSE_DATA_PREFIX_LENGTH;
let mut nodes = Vec::with_capacity(active_nodes.len());
for node in active_nodes {
let node_len = node
.payload_len(self.config.bytes_per_sample)
.ok_or(CodecError::PayloadTooLarge)?;
let next = cursor + node_len;
nodes.push(HandGatewayFrameNode {
config_mask: node.config_mask,
valid: valid_config & node.config_mask != 0,
payload: frame[cursor..next].to_vec(),
});
cursor = next;
}
Ok(HandGatewayFrame::DataRep(HandGatewayDataRepFrame {
meta: HandGatewayFrameMetaData {
header: FRAME_HEADER,
length,
cmd: RESPONSE_DATA_CMD,
version,
checksum: frame[frame.len() - 1],
},
timestamp,
config,
valid_config,
block_count,
nodes,
dts_ms: elapsed_millis(session_started_at),
}))
}
}
impl Codec<HandGatewayFrame> for HandGatewayCodec {
fn decode(
&mut self,
input: &[u8],
session_started_at: Instant,
) -> Result<Vec<HandGatewayFrame>, CodecError> {
self.config.validate()?;
self.buffer.extend_from_slice(input);
let mut frames = Vec::new();
loop {
let Some(header_pos) = self
.buffer
.windows(FRAME_HEADER.len())
.position(|window| window == FRAME_HEADER)
else {
self.retain_possible_header_prefix();
break;
};
if header_pos > 0 {
self.buffer.drain(..header_pos);
}
if self.buffer.len() < 4 {
break;
}
let length = u16::from_le_bytes([self.buffer[2], self.buffer[3]]);
let frame_len = usize::from(length)
.checked_add(4)
.ok_or(CodecError::PayloadTooLarge)?;
if frame_len < MIN_FRAME_LENGTH || frame_len > self.config.max_frame_length {
debug!("invalid hand gateway frame length: {frame_len}");
self.buffer.drain(..1);
continue;
}
if self.buffer.len() < frame_len {
break;
}
let expected_checksum = calc_crc8_itu(&self.buffer[..frame_len - 1]);
let received_checksum = self.buffer[frame_len - 1];
if expected_checksum != received_checksum {
debug!(
"hand gateway checksum mismatch: expected {expected_checksum:02X}, got {received_checksum:02X}"
);
self.buffer.drain(..1);
continue;
}
if self.buffer[4] == RESPONSE_DATA_CMD {
match self.parse_data_response(
&self.buffer[..frame_len],
length,
session_started_at,
) {
Ok(frame) => frames.push(frame),
Err(error) => debug!("invalid hand gateway data response: {error}"),
}
}
self.buffer.drain(..frame_len);
}
Ok(frames)
}
fn encode(&self, _frame: &HandGatewayFrame) -> Result<Vec<u8>, CodecError> {
Err(CodecError::InvalidFrameType)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_codec() -> HandGatewayCodec {
HandGatewayCodec::with_config(HandGatewayConfig::new(vec![
HandGatewayNodeConfig::new(1 << 0, 2),
HandGatewayNodeConfig::new(1 << 1, 1),
]))
.unwrap()
}
fn data_response(config: u32, valid_config: u32, payload: &[u8]) -> Vec<u8> {
let block_count = config.count_ones() as u8;
let length = (RESPONSE_DATA_FIXED_LENGTH + payload.len()) as u16;
let mut frame = Vec::new();
frame.extend_from_slice(&FRAME_HEADER);
frame.extend_from_slice(&length.to_le_bytes());
frame.push(RESPONSE_DATA_CMD);
frame.push(0x01);
frame.extend_from_slice(&123_456u32.to_le_bytes());
frame.extend_from_slice(&config.to_le_bytes());
frame.extend_from_slice(&valid_config.to_le_bytes());
frame.push(block_count);
frame.extend_from_slice(payload);
frame.push(calc_crc8_itu(&frame));
frame
}
#[test]
fn decodes_configured_nodes() {
let mut codec = test_codec();
let bytes = data_response(0b11, 0b01, &[1, 0, 2, 0, 3, 0]);
let frames = codec.decode(&bytes, Instant::now()).unwrap();
let HandGatewayFrame::DataRep(frame) = &frames[0];
assert_eq!(frame.timestamp, 123_456);
assert_eq!(frame.block_count, 2);
assert_eq!(frame.nodes.len(), 2);
assert_eq!(frame.nodes[0].config_mask, 1);
assert!(frame.nodes[0].valid);
assert_eq!(
HandGatewayCodec::parse_node_payload(&frame.nodes[0].payload).unwrap(),
[1, 2]
);
assert!(!frame.nodes[1].valid);
assert_eq!(
HandGatewayCodec::parse_node_payload(&frame.nodes[1].payload).unwrap(),
[3]
);
}
#[test]
fn supports_split_and_sticky_packets() {
let mut codec = test_codec();
let frame = data_response(0b01, 0b01, &[1, 0, 2, 0]);
let split = frame.len() / 2;
assert!(codec
.decode(&frame[..split], Instant::now())
.unwrap()
.is_empty());
let mut remainder_and_next = frame[split..].to_vec();
remainder_and_next.extend_from_slice(&frame);
let frames = codec.decode(&remainder_and_next, Instant::now()).unwrap();
assert_eq!(frames.len(), 2);
}
#[test]
fn resynchronizes_after_noise_and_bad_crc() {
let mut codec = test_codec();
let valid = data_response(0b10, 0b10, &[9, 0]);
let mut bad = valid.clone();
let last = bad.len() - 1;
bad[last] ^= 0xFF;
let mut bytes = vec![0x13, 0x55, 0x12];
bytes.extend_from_slice(&bad);
bytes.extend_from_slice(&valid);
let frames = codec.decode(&bytes, Instant::now()).unwrap();
assert_eq!(frames.len(), 1);
}
#[test]
fn rejects_payload_that_does_not_match_fixed_config() {
let mut codec = test_codec();
let bytes = data_response(0b01, 0b01, &[1, 0]);
assert!(codec.decode(&bytes, Instant::now()).unwrap().is_empty());
}
}

View File

@@ -2,4 +2,5 @@ use crate::serial_core::{frame::TestFrame, record::Recording};
pub mod test; pub mod test;
pub mod tactile_a; pub mod tactile_a;
pub mod hand_gateway;
pub type TestRecording = Recording<TestFrame>; pub type TestRecording = Recording<TestFrame>;

View File

@@ -14,7 +14,7 @@ use anyhow::anyhow;
use std::io::Read; use std::io::Read;
use log::debug; use log::debug;
const FRAME_BUFFER_MIN_LENGTH: usize = 15; const FINGER_FRAME_BUFFER_MIN_LENGTH: usize = 15;
pub struct TactileACodec { pub struct TactileACodec {
buffer: Vec<u8>, buffer: Vec<u8>,
@@ -123,7 +123,7 @@ impl Codec<TactileAFrame> for TactileACodec {
let mut frames: Vec<TactileAFrame> = Vec::new(); let mut frames: Vec<TactileAFrame> = Vec::new();
loop { loop {
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH { if self.buffer.len() < FINGER_FRAME_BUFFER_MIN_LENGTH {
break; break;
} }
@@ -137,7 +137,7 @@ impl Codec<TactileAFrame> for TactileACodec {
self.buffer.drain(0..pos); self.buffer.drain(0..pos);
} }
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH { if self.buffer.len() < FINGER_FRAME_BUFFER_MIN_LENGTH {
break; break;
} }
@@ -165,7 +165,7 @@ impl Codec<TactileAFrame> for TactileACodec {
continue; continue;
} }
let frame_length = except_data_len + FRAME_BUFFER_MIN_LENGTH; let frame_length = except_data_len + FINGER_FRAME_BUFFER_MIN_LENGTH;
if self.buffer.len() < frame_length { if self.buffer.len() < frame_length {
break; break;
} }

View File

@@ -7,7 +7,7 @@ pub struct TestFrame {
pub length: usize, pub length: usize,
pub payload: Vec<u8>, pub payload: Vec<u8>,
pub checksum: u8, pub checksum: u8,
pub dts_ms: u64 pub dts_ms: u64,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -35,23 +35,54 @@ pub struct TactileARepFrame {
pub meta: TactileAFrameMetaData, pub meta: TactileAFrameMetaData,
pub status: TactileAFrameStatusCode, pub status: TactileAFrameStatusCode,
pub payload: Vec<u8>, pub payload: Vec<u8>,
pub dts_ms: u64 pub dts_ms: u64,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TactileAFrameStatusCode { pub enum TactileAFrameStatusCode {
Success, Success,
Failure Failure,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TactileAFrame { pub enum TactileAFrame {
Req(TactileAReqFrame), Req(TactileAReqFrame),
Rep(TactileARepFrame) Rep(TactileARepFrame),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayFrameMetaData {
pub header: [u8; 2],
pub length: u16,
pub cmd: u8,
pub version: u8,
pub checksum: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayFrameNode {
pub config_mask: u32,
pub valid: bool,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HandGatewayDataRepFrame {
pub meta: HandGatewayFrameMetaData,
pub timestamp: u32,
pub config: u32,
pub valid_config: u32,
pub block_count: u8,
pub nodes: Vec<HandGatewayFrameNode>,
pub dts_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandGatewayFrame {
DataRep(HandGatewayDataRepFrame),
} }
#[async_trait] #[async_trait]
pub trait FrameHandler<F, T>: Send { pub trait FrameHandler<F, T>: Send {
async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>; async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>;
} }

View File

@@ -17,7 +17,6 @@ const ACTIVE_CELL_MIN_VALUE: f32 = 18.0;
const ACTIVE_CELL_PEAK_RATIO: f32 = 0.14; const ACTIVE_CELL_PEAK_RATIO: f32 = 0.14;
const MIN_ACTIVE_CELLS: usize = 3; const MIN_ACTIVE_CELLS: usize = 3;
const ANCHOR_LERP_ALPHA: f32 = 0.018;
const VECTOR_SMOOTHING_ALPHA: f32 = 0.16; const VECTOR_SMOOTHING_ALPHA: f32 = 0.16;
const REPORT_MAGNITUDE_ENTER: f32 = 0.12; const REPORT_MAGNITUDE_ENTER: f32 = 0.12;
@@ -29,6 +28,12 @@ const REPORT_HOLD_FRAMES: usize = 10;
const ASYMMETRY_WEIGHT: f32 = 1.1; const ASYMMETRY_WEIGHT: f32 = 1.1;
const DRIFT_WEIGHT: f32 = 0.65; const DRIFT_WEIGHT: f32 = 0.65;
const MOTION_WEIGHT: f32 = 0.25; const MOTION_WEIGHT: f32 = 0.25;
const EDGE_ASYMMETRY_DAMPING: f32 = 0.35;
const EDGE_INWARD_ROLLING_BIAS: f32 = 0.55;
const EDGE_START_COP_THRESHOLD: f32 = 0.45;
const EDGE_START_BIAS_WEIGHT: f32 = 1.1;
const ROLLING_FRICTION_ALPHA: f32 = 0.68;
const ROLLING_FRICTION_MIN_MAGNITUDE: f32 = 0.05;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct PztSpatialAnalysis { pub struct PztSpatialAnalysis {
@@ -50,6 +55,8 @@ pub struct PztProcessor {
anchor_cop_y: Option<f32>, anchor_cop_y: Option<f32>,
last_cop_x: Option<f32>, last_cop_x: Option<f32>,
last_cop_y: Option<f32>, last_cop_y: Option<f32>,
edge_start_bias_x: f32,
edge_start_bias_y: f32,
smoothed_x: f32, smoothed_x: f32,
smoothed_y: f32, smoothed_y: f32,
report_active: bool, report_active: bool,
@@ -84,6 +91,8 @@ impl PztProcessor {
anchor_cop_y: None, anchor_cop_y: None,
last_cop_x: None, last_cop_x: None,
last_cop_y: None, last_cop_y: None,
edge_start_bias_x: 0.0,
edge_start_bias_y: 0.0,
smoothed_x: 0.0, smoothed_x: 0.0,
smoothed_y: 0.0, smoothed_y: 0.0,
report_active: false, report_active: false,
@@ -100,6 +109,8 @@ impl PztProcessor {
self.anchor_cop_y = None; self.anchor_cop_y = None;
self.last_cop_x = None; self.last_cop_x = None;
self.last_cop_y = None; self.last_cop_y = None;
self.edge_start_bias_x = 0.0;
self.edge_start_bias_y = 0.0;
self.smoothed_x = 0.0; self.smoothed_x = 0.0;
self.smoothed_y = 0.0; self.smoothed_y = 0.0;
} }
@@ -273,6 +284,112 @@ impl PztProcessor {
(angle, magnitude) (angle, magnitude)
} }
fn contact_touches_edge(stats: &ContactStats) -> bool {
stats.min_row == 0
|| stats.max_row == SENSOR_ROWS - 1
|| stats.min_col == 0
|| stats.max_col == SENSOR_COLS - 1
}
fn damp_edge_asymmetry(
stats: &ContactStats,
kinematic_x: f32,
kinematic_y: f32,
) -> (f32, f32) {
let mut asymmetry_x = stats.asymmetry_x * ASYMMETRY_WEIGHT;
let mut asymmetry_y = stats.asymmetry_y * ASYMMETRY_WEIGHT;
if stats.min_col == 0 && asymmetry_x < 0.0 {
asymmetry_x = -asymmetry_x * EDGE_INWARD_ROLLING_BIAS;
}
if stats.max_col == SENSOR_COLS - 1 && asymmetry_x > 0.0 {
asymmetry_x = -asymmetry_x * EDGE_INWARD_ROLLING_BIAS;
}
if stats.min_row == 0 && asymmetry_y < 0.0 {
asymmetry_y = -asymmetry_y * EDGE_INWARD_ROLLING_BIAS;
}
if stats.max_row == SENSOR_ROWS - 1 && asymmetry_y > 0.0 {
asymmetry_y = -asymmetry_y * EDGE_INWARD_ROLLING_BIAS;
}
if Self::contact_touches_edge(stats) {
let opposing_dot = asymmetry_x * kinematic_x + asymmetry_y * kinematic_y;
let kinematic_mag = (kinematic_x * kinematic_x + kinematic_y * kinematic_y).sqrt();
if opposing_dot < 0.0 && kinematic_mag >= ROLLING_FRICTION_MIN_MAGNITUDE {
asymmetry_x *= EDGE_ASYMMETRY_DAMPING;
asymmetry_y *= EDGE_ASYMMETRY_DAMPING;
}
}
(asymmetry_x, asymmetry_y)
}
fn edge_start_bias(stats: &ContactStats) -> (f32, f32) {
let center_x = (SENSOR_COLS - 1) as f32 * 0.5;
let center_y = (SENSOR_ROWS - 1) as f32 * 0.5;
let normalized_x = ((stats.cop_x - center_x) / center_x.max(1.0)).clamp(-1.0, 1.0);
let normalized_y = ((stats.cop_y - center_y) / center_y.max(1.0)).clamp(-1.0, 1.0);
let mut bias_x = 0.0;
let mut bias_y = 0.0;
if stats.min_col == 0 || stats.max_col == SENSOR_COLS - 1 {
bias_x = Self::edge_start_axis_bias(normalized_x);
}
if stats.min_row == 0 || stats.max_row == SENSOR_ROWS - 1 {
bias_y = Self::edge_start_axis_bias(normalized_y);
}
(bias_x, bias_y)
}
fn edge_start_axis_bias(normalized_axis: f32) -> f32 {
let distance = normalized_axis.abs();
if distance <= EDGE_START_COP_THRESHOLD {
return 0.0;
}
let strength = ((distance - EDGE_START_COP_THRESHOLD) / (1.0 - EDGE_START_COP_THRESHOLD))
.clamp(0.0, 1.0);
-normalized_axis.signum() * strength * EDGE_START_BIAS_WEIGHT
}
fn apply_rolling_friction(
previous_x: f32,
previous_y: f32,
current_x: f32,
current_y: f32,
) -> (f32, f32) {
let previous_mag = (previous_x * previous_x + previous_y * previous_y).sqrt();
let current_mag = (current_x * current_x + current_y * current_y).sqrt();
if previous_mag < ROLLING_FRICTION_MIN_MAGNITUDE
|| current_mag < ROLLING_FRICTION_MIN_MAGNITUDE
{
return (current_x, current_y);
}
let dot = previous_x * current_x + previous_y * current_y;
if dot >= 0.0 {
return (current_x, current_y);
}
let mixed_x = current_x * (1.0 - ROLLING_FRICTION_ALPHA)
+ previous_x * ROLLING_FRICTION_ALPHA;
let mixed_y = current_y * (1.0 - ROLLING_FRICTION_ALPHA)
+ previous_y * ROLLING_FRICTION_ALPHA;
if mixed_x * previous_x + mixed_y * previous_y >= 0.0 {
return (mixed_x, mixed_y);
}
let keep_mag = previous_mag.min(current_mag) * 0.5;
(
previous_x / previous_mag * keep_mag,
previous_y / previous_mag * keep_mag,
)
}
fn update_contact_state(&mut self, raw_frame: &[f32], frame: &[f32]) -> bool { fn update_contact_state(&mut self, raw_frame: &[f32], frame: &[f32]) -> bool {
if self.contact_active { if self.contact_active {
if Self::is_contact_exit_frame(frame) { if Self::is_contact_exit_frame(frame) {
@@ -376,6 +493,9 @@ impl PztProcessor {
self.anchor_cop_y = Some(stats.cop_y); self.anchor_cop_y = Some(stats.cop_y);
self.last_cop_x = Some(stats.cop_x); self.last_cop_x = Some(stats.cop_x);
self.last_cop_y = Some(stats.cop_y); self.last_cop_y = Some(stats.cop_y);
let (edge_start_bias_x, edge_start_bias_y) = Self::edge_start_bias(&stats);
self.edge_start_bias_x = edge_start_bias_x;
self.edge_start_bias_y = edge_start_bias_y;
return Ok(self.stabilize_report(Self::weak_contact_analysis())); return Ok(self.stabilize_report(Self::weak_contact_analysis()));
}; };
@@ -388,18 +508,25 @@ impl PztProcessor {
let motion_x = stats.cop_x - last_x; let motion_x = stats.cop_x - last_x;
let motion_y = stats.cop_y - last_y; let motion_y = stats.cop_y - last_y;
let combined_x = stats.asymmetry_x * ASYMMETRY_WEIGHT let kinematic_x = drift_x * DRIFT_WEIGHT + motion_x * MOTION_WEIGHT;
+ drift_x * DRIFT_WEIGHT let kinematic_y = drift_y * DRIFT_WEIGHT + motion_y * MOTION_WEIGHT;
+ motion_x * MOTION_WEIGHT; let edge_bias_x = self.edge_start_bias_x;
let combined_y = stats.asymmetry_y * ASYMMETRY_WEIGHT let edge_bias_y = self.edge_start_bias_y;
+ drift_y * DRIFT_WEIGHT let (asymmetry_x, asymmetry_y) =
+ motion_y * MOTION_WEIGHT; Self::damp_edge_asymmetry(&stats, kinematic_x + edge_bias_x, kinematic_y + edge_bias_y);
let combined_x = asymmetry_x + kinematic_x + edge_bias_x;
let combined_y = asymmetry_y + kinematic_y + edge_bias_y;
let (combined_x, combined_y) = Self::apply_rolling_friction(
self.smoothed_x,
self.smoothed_y,
combined_x,
combined_y,
);
self.smoothed_x += (combined_x - self.smoothed_x) * VECTOR_SMOOTHING_ALPHA; self.smoothed_x += (combined_x - self.smoothed_x) * VECTOR_SMOOTHING_ALPHA;
self.smoothed_y += (combined_y - self.smoothed_y) * VECTOR_SMOOTHING_ALPHA; self.smoothed_y += (combined_y - self.smoothed_y) * VECTOR_SMOOTHING_ALPHA;
self.anchor_cop_x = Some(anchor_x + drift_x * ANCHOR_LERP_ALPHA);
self.anchor_cop_y = Some(anchor_y + drift_y * ANCHOR_LERP_ALPHA);
self.last_cop_x = Some(stats.cop_x); self.last_cop_x = Some(stats.cop_x);
self.last_cop_y = Some(stats.cop_y); self.last_cop_y = Some(stats.cop_y);
@@ -446,7 +573,7 @@ impl PztProcessor {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{PztProcessor, SENSOR_COLS, SENSOR_ROWS}; use super::{ContactStats, PztProcessor, SENSOR_COLS, SENSOR_ROWS};
fn index(row: usize, col: usize) -> usize { fn index(row: usize, col: usize) -> usize {
row * SENSOR_COLS + col row * SENSOR_COLS + col
@@ -460,6 +587,23 @@ mod tests {
frame frame
} }
fn stats_touching_bottom_edge() -> ContactStats {
ContactStats {
total: 1000.0,
peak: 300.0,
active_total: 900.0,
active_cells: 6,
min_row: SENSOR_ROWS - 2,
max_row: SENSOR_ROWS - 1,
min_col: 2,
max_col: 4,
cop_x: 3.0,
cop_y: 10.5,
asymmetry_x: 0.0,
asymmetry_y: 1.0,
}
}
#[test] #[test]
fn idle_frame_does_not_report_contact() { fn idle_frame_does_not_report_contact() {
let mut processor = PztProcessor::new(); let mut processor = PztProcessor::new();
@@ -524,4 +668,29 @@ mod tests {
let analysis = processor.get_pzt_analysis(&weak).unwrap(); let analysis = processor.get_pzt_analysis(&weak).unwrap();
assert!(analysis.reportable); assert!(analysis.reportable);
} }
#[test]
fn bottom_edge_outward_gradient_is_turned_inward() {
let stats = stats_touching_bottom_edge();
let (_asymmetry_x, asymmetry_y) = PztProcessor::damp_edge_asymmetry(&stats, 0.0, -0.2);
assert!(asymmetry_y < 0.0);
assert!(asymmetry_y > -1.1);
}
#[test]
fn bottom_edge_start_adds_fixed_upward_bias() {
let stats = stats_touching_bottom_edge();
let (_bias_x, bias_y) = PztProcessor::edge_start_bias(&stats);
assert!(bias_y < 0.0);
}
#[test]
fn rolling_friction_resists_one_frame_reversal() {
let (x, y) = PztProcessor::apply_rolling_friction(0.4, 0.0, -0.6, 0.0);
assert!(x > 0.0);
assert_eq!(y, 0.0);
}
} }

View File

@@ -1,6 +1,8 @@
use crate::ad_solver::solve_for_x;
#[cfg(feature = "devkit")] #[cfg(feature = "devkit")]
use crate::devkit::{proto::SensorFrame, DevKitState}; use crate::devkit::{proto::SensorFrame, DevKitState};
use crate::serial_core::codec::Codec; use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::hand_gateway::HandGatewayCodec;
use crate::serial_core::codecs::tactile_a::TactileACodec; use crate::serial_core::codecs::tactile_a::TactileACodec;
use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame}; use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame};
use crate::serial_core::model::{HudChartState, HudPacket, HudSpatialForce}; use crate::serial_core::model::{HudChartState, HudPacket, HudSpatialForce};
@@ -10,6 +12,7 @@ use crate::serial_core::record::Recording;
use crate::serial_core::record::{FrameTiming, RecordedFrame}; use crate::serial_core::record::{FrameTiming, RecordedFrame};
use anyhow::Result; use anyhow::Result;
use log::debug; use log::debug;
use log::info;
use std::future::pending; use std::future::pending;
#[cfg(feature = "devkit")] #[cfg(feature = "devkit")]
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@@ -22,7 +25,6 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{self, Duration, MissedTickBehavior}; use tokio::time::{self, Duration, MissedTickBehavior};
use tokio_serial::SerialStream; use tokio_serial::SerialStream;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::ad_solver::solve_for_x;
const AUTO_SUB_INTERVAL: Duration = Duration::from_nanos(16_666_667); const AUTO_SUB_INTERVAL: Duration = Duration::from_nanos(16_666_667);
pub enum PollMode<F> { pub enum PollMode<F> {
@@ -200,6 +202,61 @@ where
.await .await
} }
/// Runs the hand gateway in passive mode. The MCU continuously uploads data, so
/// this loop never writes request frames to the serial port.
pub async fn run_hand_gateway_serial(
app: AppHandle,
mut port: SerialStream,
mut codec: HandGatewayCodec,
session_started_at: Instant,
cancel: CancellationToken,
) -> Result<()> {
let mut buffer = [0u8; 4096];
loop {
tokio::select! {
_ = cancel.cancelled() => break,
read_result = port.read(&mut buffer) => {
let n = read_result?;
if n == 0 {
tokio::task::yield_now().await;
continue;
}
let raw = &buffer[..n];
app.emit("hand_gateway_raw", raw.to_vec())?;
let raw_hex = raw
.iter()
.map(|byte| format!("{byte:02X}"))
.collect::<Vec<_>>()
.join(" ");
info!("[hand_gateway][raw] {raw_hex}");
for frame in codec.decode(raw, session_started_at)? {
let crate::serial_core::frame::HandGatewayFrame::DataRep(frame) = frame;
info!(
"[hand_gateway][frame] timestamp_us={} config=0x{:08X} valid_config=0x{:08X} block_count={}",
frame.timestamp,
frame.config,
frame.valid_config,
frame.block_count,
);
for node in frame.nodes {
info!(
"[hand_gateway][node] mask=0x{:08X} valid={} payload={:02X?}",
node.config_mask,
node.valid,
node.payload,
);
}
}
}
}
}
Ok(())
}
pub async fn run_serial_with_poll<C, H, T, F>( pub async fn run_serial_with_poll<C, H, T, F>(
app: AppHandle, app: AppHandle,
mut port: SerialStream, mut port: SerialStream,
@@ -421,7 +478,8 @@ fn infer_matrix_shape(len: usize) -> (u32, u32) {
fn raw_to_g1(raw: u32) -> f64 { fn raw_to_g1(raw: u32) -> f64 {
const X: [u32; 13] = [ const X: [u32; 13] = [
0, 16811, 41350, 79241, 94615, 127446, 149559, 175900, 195056, 237852, 267810, 322472, 378511, 0, 16811, 41350, 79241, 94615, 127446, 149559, 175900, 195056, 237852, 267810, 322472,
378511,
]; ];
const Y: [f64; 13] = [ const Y: [f64; 13] = [

View File

@@ -48,7 +48,7 @@
export let colorMapPreset: PressureColorMapPreset = "emerald"; export let colorMapPreset: PressureColorMapPreset = "emerald";
export let matrixDisplayMode: MatrixDisplayMode = "dots"; export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let stageViewMode: StageViewMode = "webgl"; export let stageViewMode: StageViewMode = "webgl";
export let modelUrl = "/models/je-skin-model.glb"; export let modelUrl = "/models/je-skin-model.gltf";
export let replaySectionLabel = ""; export let replaySectionLabel = "";
export let replayPlayLabel = ""; export let replayPlayLabel = "";
export let replayPauseLabel = ""; export let replayPauseLabel = "";

View File

@@ -19,43 +19,15 @@
const FLOOR_Y = -1.15; const FLOOR_Y = -1.15;
const MODEL_FLOOR_CLEARANCE = 0.035; const MODEL_FLOOR_CLEARANCE = 0.035;
const MODEL_TARGET_HEIGHT = 8.4; const MODEL_TARGET_HEIGHT = 6.55;
const MODEL_MIN_SCALE = 0.02; const MODEL_MIN_SCALE = 0.02;
const MODEL_MAX_SCALE = 80; const MODEL_MAX_SCALE = 80;
const CAMERA_DISTANCE_FACTOR = 1.35; const FIXED_CAMERA_POSITION = new THREE.Vector3(-0.113, -0.149, 10.911);
const CAMERA_DISTANCE_MIN = 7.5; const FIXED_CAMERA_TARGET = new THREE.Vector3(0, 2.35, 0);
const CAMERA_DISTANCE_MAX = 24; const MODEL_FRONT_ROTATION_Y = -Math.PI / 2;
const MODEL_UPRIGHT_ROTATION_Z = Math.PI * 1.5 - THREE.MathUtils.degToRad(2.2);
$: copy = $: canvasLabel = locale === "zh-CN" ? "3D 模型" : "3D Model";
locale === "zh-CN"
? {
title: "3D 模型舱",
subtitle: "Dark Grid / Future Lab",
loading: "正在加载模型",
ready: "模型已载入",
missing: "等待模型文件",
error: "模型加载失败",
modelPath: "模型路径",
hint: "请使用 glTF 2.0 的 .glb/.gltf旧版 glTF 1.0 需要先转换"
}
: {
title: "3D Model Bay",
subtitle: "Dark Grid / Future Lab",
loading: "Loading model",
ready: "Model loaded",
missing: "Waiting for model file",
error: "Model load failed",
modelPath: "Model path",
hint: "Use glTF 2.0 .glb/.gltf assets; older glTF 1.0 files need conversion first"
};
$: statusText =
loadState === "ready"
? copy.ready
: loadState === "missing"
? copy.missing
: loadState === "error"
? copy.error
: `${copy.loading} ${Math.round(loadProgress)}%`;
function disposeObject3D(object: THREE.Object3D): void { function disposeObject3D(object: THREE.Object3D): void {
object.traverse((child) => { object.traverse((child) => {
@@ -128,7 +100,41 @@
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
function materialToUnlit(material: THREE.Material): THREE.Material {
const source = material as THREE.MeshStandardMaterial & THREE.MeshPhysicalMaterial;
const unlit = new THREE.MeshBasicMaterial({
color: source.color?.clone() ?? new THREE.Color(0xffffff),
map: source.map ?? null,
transparent: source.transparent,
opacity: source.opacity,
alphaMap: source.alphaMap ?? null,
side: source.side,
depthWrite: source.depthWrite,
depthTest: source.depthTest,
vertexColors: source.vertexColors,
toneMapped: false
});
return unlit;
}
function applyUnlitMaterials(object: THREE.Object3D): void {
object.traverse((child) => {
const mesh = child as THREE.Mesh;
if (!mesh.isMesh || !mesh.material) {
return;
}
mesh.castShadow = false;
mesh.receiveShadow = false;
mesh.material = Array.isArray(mesh.material)
? mesh.material.map(materialToUnlit)
: materialToUnlit(mesh.material);
});
}
function normalizeObjectToStage(object: THREE.Object3D): THREE.Box3 { function normalizeObjectToStage(object: THREE.Object3D): THREE.Box3 {
object.rotation.set(0, MODEL_FRONT_ROTATION_Y, MODEL_UPRIGHT_ROTATION_Z);
object.updateMatrixWorld(true); object.updateMatrixWorld(true);
let bounds = new THREE.Box3().setFromObject(object); let bounds = new THREE.Box3().setFromObject(object);
const size = bounds.getSize(new THREE.Vector3()); const size = bounds.getSize(new THREE.Vector3());
@@ -149,19 +155,14 @@
} }
function frameObject(object: THREE.Object3D, camera: THREE.PerspectiveCamera, controls: OrbitControls): void { function frameObject(object: THREE.Object3D, camera: THREE.PerspectiveCamera, controls: OrbitControls): void {
const bounds = normalizeObjectToStage(object); normalizeObjectToStage(object);
const size = bounds.getSize(new THREE.Vector3()); camera.position.copy(FIXED_CAMERA_POSITION);
const maxAxis = Math.max(size.x, size.y, size.z, 1); camera.near = 0.05;
const distance = clamp(maxAxis * CAMERA_DISTANCE_FACTOR, CAMERA_DISTANCE_MIN, CAMERA_DISTANCE_MAX); camera.far = 600;
const targetY = FLOOR_Y + Math.max(size.y * 0.46, 1.4);
camera.position.set(distance * 0.48, targetY + distance * 0.24, distance * 0.68);
camera.near = Math.max(distance / 80, 0.01);
camera.far = distance * 24;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
controls.target.set(0, targetY, 0); controls.target.copy(FIXED_CAMERA_TARGET);
controls.minDistance = Math.max(distance * 0.32, 2); controls.minDistance = 2.4;
controls.maxDistance = Math.max(distance * 2.5, 12); controls.maxDistance = 32;
controls.update(); controls.update();
} }
@@ -186,14 +187,15 @@
scene.fog = new THREE.FogExp2(0x03070d, 0.028); scene.fog = new THREE.FogExp2(0x03070d, 0.028);
const camera = new THREE.PerspectiveCamera(38, 1, 0.05, 600); const camera = new THREE.PerspectiveCamera(38, 1, 0.05, 600);
camera.position.set(8, 6, 9); camera.position.copy(FIXED_CAMERA_POSITION);
camera.lookAt(FIXED_CAMERA_TARGET);
const controls = new OrbitControls(camera, canvasEl); const controls = new OrbitControls(camera, canvasEl);
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.08; controls.dampingFactor = 0.08;
controls.minDistance = 2.4; controls.minDistance = 2.4;
controls.maxDistance = 32; controls.maxDistance = 32;
controls.target.set(0, FLOOR_Y + 3.2, 0); controls.target.copy(FIXED_CAMERA_TARGET);
const labGroup = new THREE.Group(); const labGroup = new THREE.Group();
scene.add(labGroup); scene.add(labGroup);
@@ -241,16 +243,8 @@
floor.position.y = FLOOR_Y - 0.018; floor.position.y = FLOOR_Y - 0.018;
labGroup.add(floor); labGroup.add(floor);
const ambient = new THREE.AmbientLight(0x9fb8d0, 0.22);
const keyLight = new THREE.DirectionalLight(0x7be7ff, 1.5);
keyLight.position.set(8, 12, 8);
const rimLight = new THREE.PointLight(0xa6ff7a, 26, 24, 2.1);
rimLight.position.set(-4.5, 4.8, -3.6);
const sideLight = new THREE.PointLight(0x5c8cff, 15, 28, 1.7);
sideLight.position.set(5.8, 3.2, -5.4);
scene.add(ambient, keyLight, rimLight, sideLight);
let activeModel: THREE.Object3D = buildPlaceholderModel(); let activeModel: THREE.Object3D = buildPlaceholderModel();
applyUnlitMaterials(activeModel);
scene.add(activeModel); scene.add(activeModel);
frameObject(activeModel, camera, controls); frameObject(activeModel, camera, controls);
@@ -261,13 +255,7 @@
scene.remove(activeModel); scene.remove(activeModel);
disposeObject3D(activeModel); disposeObject3D(activeModel);
activeModel = gltf.scene; activeModel = gltf.scene;
activeModel.traverse((child) => { applyUnlitMaterials(activeModel);
const mesh = child as THREE.Mesh;
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = true;
}
});
scene.add(activeModel); scene.add(activeModel);
frameObject(activeModel, camera, controls); frameObject(activeModel, camera, controls);
loadState = "ready"; loadState = "ready";
@@ -330,20 +318,9 @@
</script> </script>
<div class="model-stage" bind:this={rootEl}> <div class="model-stage" bind:this={rootEl}>
<canvas class="model-canvas" bind:this={canvasEl} aria-label={copy.title}></canvas> <canvas class="model-canvas" bind:this={canvasEl} aria-label={canvasLabel}></canvas>
<div class="model-vignette" aria-hidden="true"></div> <div class="model-vignette" aria-hidden="true"></div>
<div class="model-scanlines" aria-hidden="true"></div> <div class="model-scanlines" aria-hidden="true"></div>
<section class="model-hud" aria-label={copy.title}>
<p class="model-kicker">{copy.subtitle}</p>
<h2>{copy.title}</h2>
<div class="model-status-row">
<span class="status-light" class:is-ready={loadState === "ready"}></span>
<span>{statusText}</span>
</div>
<p class="model-path">{copy.modelPath}: {modelUrl}</p>
<p class="model-hint">{loadError || copy.hint}</p>
</section>
</div> </div>
<style> <style>
@@ -388,82 +365,4 @@
mix-blend-mode: screen; mix-blend-mode: screen;
} }
.model-hud {
position: absolute;
top: clamp(1.2rem, 2.8vw, 2.2rem);
left: clamp(1.2rem, 2.8vw, 2.4rem);
z-index: 2;
display: grid;
gap: 0.42rem;
max-inline-size: min(22rem, 42vw);
padding: 0.9rem 1rem 1rem;
border: 1px solid rgb(94 231 255 / 0.24);
border-radius: 0.7rem;
background:
linear-gradient(180deg, rgb(8 18 28 / 0.82), rgb(3 9 15 / 0.72)),
radial-gradient(circle at 0 0, rgb(94 231 255 / 0.1), transparent 44%);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.06),
0 0 28px rgb(94 231 255 / 0.08);
backdrop-filter: blur(10px);
}
.model-kicker,
.model-path,
.model-hint {
margin: 0;
color: rgb(198 226 239 / 0.72);
font-size: 0.6rem;
letter-spacing: 0.1em;
text-transform: uppercase;
line-height: 1.35;
}
h2 {
margin: 0;
color: rgb(241 251 255 / 0.96);
font-size: clamp(1.15rem, 1.1vw + 0.88rem, 1.72rem);
line-height: 1.05;
font-weight: 650;
}
.model-status-row {
display: inline-flex;
align-items: center;
gap: 0.44rem;
color: rgb(229 249 255 / 0.94);
font-size: 0.78rem;
letter-spacing: 0.04em;
}
.status-light {
inline-size: 0.58rem;
block-size: 0.58rem;
border-radius: 50%;
background: rgb(255 188 92 / 0.95);
box-shadow: 0 0 0 2px rgb(255 188 92 / 0.16), 0 0 12px rgb(255 188 92 / 0.18);
}
.status-light.is-ready {
background: rgb(166 255 122 / 0.95);
box-shadow: 0 0 0 2px rgb(166 255 122 / 0.16), 0 0 14px rgb(166 255 122 / 0.22);
}
.model-path {
color: rgb(94 231 255 / 0.78);
text-transform: none;
word-break: break-word;
}
.model-hint {
color: rgb(198 226 239 / 0.66);
text-transform: none;
letter-spacing: 0.04em;
}
@media (max-width: 960px) {
.model-hud {
max-inline-size: min(20rem, calc(100% - 2.4rem));
}
}
</style> </style>

View File

@@ -306,6 +306,10 @@
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
} }
function serialProtocolForStage(mode: StageViewMode = stageViewMode): "tactileA" | "handGateway" {
return mode === "model3d" ? "handGateway" : "tactileA";
}
function clearDevkitSpatialForce(): void { function clearDevkitSpatialForce(): void {
devkitSpatialForce = null; devkitSpatialForce = null;
if (devkitSpatialForceClearTimer != null && typeof window !== "undefined") { if (devkitSpatialForceClearTimer != null && typeof window !== "undefined") {
@@ -1445,7 +1449,10 @@
connectionNotice = ""; connectionNotice = "";
try { try {
const result = await invoke<SerialConnectResult>("serial_connect", { port: event.detail }); const result = await invoke<SerialConnectResult>("serial_connect", {
port: event.detail,
protocol: serialProtocolForStage()
});
connectionState = result.connected ? "online" : "offline"; connectionState = result.connected ? "online" : "offline";
serialPortValue = result.port; serialPortValue = result.port;
connectionNotice = ""; connectionNotice = "";
@@ -1800,17 +1807,48 @@
matrixDisplayMode = event.detail ? "dots" : "numeric"; matrixDisplayMode = event.detail ? "dots" : "numeric";
} }
function handleStageModeChange(event: CustomEvent<StageViewMode>): void { async function handleStageModeChange(event: CustomEvent<StageViewMode>): Promise<void> {
const previousMode = stageViewMode;
stageViewMode = event.detail; stageViewMode = event.detail;
if (stageViewMode === "model3d") { if (stageViewMode === "model3d") {
isPrecisionTestOpen = false; isPrecisionTestOpen = false;
isConfigPanelOpen = false; isConfigPanelOpen = false;
} }
if (
previousMode === stageViewMode ||
connectionState !== "online" ||
!serialPortValue ||
!isTauriRuntime()
) {
return;
}
connectionState = "connecting";
connectionNotice = "";
try {
await invoke<SerialConnectResult>("serial_disconnect");
const result = await invoke<SerialConnectResult>("serial_connect", {
port: serialPortValue,
protocol: serialProtocolForStage(stageViewMode)
});
connectionState = result.connected ? "online" : "offline";
serialPortValue = result.port;
clearHudPanels();
console.info(`[serial] switched protocol to ${serialProtocolForStage(stageViewMode)}`);
} catch (error) {
connectionState = "offline";
connectionNotice = resolveSerialNotice(error, "connect");
connectionNoticeTone = "warn";
clearHudPanels();
console.error("Serial protocol switch failed:", error);
}
} }
onMount(() => { onMount(() => {
let disposed = false; let disposed = false;
let unlistenHudStream: UnlistenFn | null = null; let unlistenHudStream: UnlistenFn | null = null;
let unlistenHandGatewayRaw: UnlistenFn | null = null;
let unlistenDevkitPztAngle: UnlistenFn | null = null; let unlistenDevkitPztAngle: UnlistenFn | null = null;
let stopMockFeed: (() => void) | null = null; let stopMockFeed: (() => void) | null = null;
@@ -1835,6 +1873,23 @@
.catch((error) => { .catch((error) => {
console.error("Failed to listen for hud_stream:", error); console.error("Failed to listen for hud_stream:", error);
}); });
void listen<number[]>("hand_gateway_raw", (event) => {
const rawHex = event.payload
.map((byte) => byte.toString(16).padStart(2, "0").toUpperCase())
.join(" ");
console.info(`[hand_gateway][raw] ${rawHex}`);
})
.then((unlisten) => {
if (disposed) {
unlisten();
return;
}
unlistenHandGatewayRaw = unlisten;
})
.catch((error) => {
console.error("Failed to listen for hand_gateway_raw:", error);
});
void listen<DevKitPztAngleEvent>("devkit_pzt_angle", (event) => { void listen<DevKitPztAngleEvent>("devkit_pzt_angle", (event) => {
const angleDeg = Number(event.payload.angle); const angleDeg = Number(event.payload.angle);
if (!Number.isFinite(angleDeg)) { if (!Number.isFinite(angleDeg)) {
@@ -1875,6 +1930,7 @@
clearDevkitSpatialForce(); clearDevkitSpatialForce();
stopMockFeed?.(); stopMockFeed?.();
unlistenHudStream?.(); unlistenHudStream?.();
unlistenHandGatewayRaw?.();
unlistenDevkitPztAngle?.(); unlistenDevkitPztAngle?.();
if (devkitStatusTimer != null) { if (devkitStatusTimer != null) {
window.clearInterval(devkitStatusTimer); window.clearInterval(devkitStatusTimer);

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long