Compare commits
4 Commits
011bfe2450
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd30e0e6af | ||
|
|
f55bd20288 | ||
|
|
91307f1985 | ||
|
|
b343e74a12 |
27
.github/workflows/playwright.yml
vendored
Normal file
27
.github/workflows/playwright.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Playwright Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: npx playwright test
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -37,3 +37,11 @@ vite.config.ts.timestamp-*
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
|||||||
82
package-lock.json
generated
82
package-lock.json
generated
@@ -16,10 +16,12 @@
|
|||||||
"three": "^0.183.2"
|
"three": "^0.183.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "^26.0.0",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
@@ -526,6 +528,22 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -1321,6 +1339,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
|
||||||
|
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/stats.js": {
|
"node_modules/@types/stats.js": {
|
||||||
"version": "0.17.4",
|
"version": "0.17.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
@@ -1686,6 +1714,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
@@ -1918,6 +1993,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
|
|||||||
@@ -22,10 +22,12 @@
|
|||||||
"three": "^0.183.2"
|
"three": "^0.183.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "^26.0.0",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
|
|||||||
79
playwright.config.ts
Normal file
79
playwright.config.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('')`. */
|
||||||
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// url: 'http://localhost:3000',
|
||||||
|
// reuseExistingServer: !process.env.CI,
|
||||||
|
// },
|
||||||
|
});
|
||||||
@@ -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, 84, 84, 84, 84, 70, 44];
|
||||||
|
|
||||||
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,6 +178,8 @@ 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 run_result = match protocol {
|
||||||
|
SerialProtocol::TactileA => {
|
||||||
let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS);
|
let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS);
|
||||||
let handler = TactileAHandler;
|
let handler = TactileAHandler;
|
||||||
let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new(
|
let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new(
|
||||||
@@ -169,23 +189,40 @@ pub async fn serial_connect(
|
|||||||
Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS),
|
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
|
||||||
|
.clone()
|
||||||
|
.expect("tactile protocol must have a recording"),
|
||||||
task_cancel,
|
task_cancel,
|
||||||
poll_mode,
|
poll_mode,
|
||||||
)
|
)
|
||||||
.await
|
.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 {
|
||||||
|
|||||||
390
src-tauri/src/serial_core/codecs/hand_gateway.rs
Normal file
390
src-tauri/src/serial_core/codecs/hand_gateway.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,76 @@ 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,
|
||||||
|
);
|
||||||
|
let mut pressure_matrix = Vec::new();
|
||||||
|
for node in &frame.nodes {
|
||||||
|
info!(
|
||||||
|
"[hand_gateway][node] mask=0x{:08X} valid={} payload={:02X?}",
|
||||||
|
node.config_mask,
|
||||||
|
node.valid,
|
||||||
|
node.payload,
|
||||||
|
);
|
||||||
|
if node.valid {
|
||||||
|
pressure_matrix.extend(
|
||||||
|
HandGatewayCodec::parse_node_payload(&node.payload)?
|
||||||
|
.into_iter()
|
||||||
|
.map(f32::from),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let sample_count = node.payload.len() / 2;
|
||||||
|
pressure_matrix.extend(std::iter::repeat(0.0).take(sample_count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pressure_matrix.is_empty() {
|
||||||
|
app.emit("hand_gateway_pressure_matrix", pressure_matrix)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +493,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] = [
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
@@ -188,7 +188,14 @@
|
|||||||
{#if isModelStage}
|
{#if isModelStage}
|
||||||
<div class="canvas-wrap">
|
<div class="canvas-wrap">
|
||||||
{#key modelUrl}
|
{#key modelUrl}
|
||||||
<ModelStage {locale} {modelUrl} />
|
<ModelStage
|
||||||
|
{locale}
|
||||||
|
{modelUrl}
|
||||||
|
{pressureMatrix}
|
||||||
|
{rangeMin}
|
||||||
|
{rangeMax}
|
||||||
|
{colorMapPreset}
|
||||||
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
{:else if showPrecisionTestPanel}
|
{:else if showPrecisionTestPanel}
|
||||||
|
|||||||
@@ -4,12 +4,27 @@
|
|||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
import type { LocaleCode } from "$lib/types/hud";
|
import { pressureColorPalettes } from "$lib/config/color-map";
|
||||||
|
import type { LocaleCode, PressureColorMapPreset } from "$lib/types/hud";
|
||||||
|
|
||||||
type ModelLoadState = "loading" | "ready" | "missing" | "error";
|
type ModelLoadState = "loading" | "ready" | "missing" | "error";
|
||||||
|
type SensorRegion = {
|
||||||
|
id: string;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
};
|
||||||
|
|
||||||
export let locale: LocaleCode = "zh-CN";
|
export let locale: LocaleCode = "zh-CN";
|
||||||
export let modelUrl = "/models/je-skin-model.glb";
|
export let modelUrl = "/models/je-skin-model.glb";
|
||||||
|
export let pressureMatrix: number[] | null = null;
|
||||||
|
export let rangeMin = 0;
|
||||||
|
export let rangeMax = 4095;
|
||||||
|
export let colorMapPreset: PressureColorMapPreset = "emerald";
|
||||||
|
|
||||||
let rootEl: HTMLDivElement | undefined;
|
let rootEl: HTMLDivElement | undefined;
|
||||||
let canvasEl: HTMLCanvasElement | undefined;
|
let canvasEl: HTMLCanvasElement | undefined;
|
||||||
@@ -19,43 +34,95 @@
|
|||||||
|
|
||||||
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 SENSOR_Z = 0.91;
|
||||||
const CAMERA_DISTANCE_MIN = 7.5;
|
const FIXED_CAMERA_POSITION = new THREE.Vector3(-0.113, -0.149, 10.911);
|
||||||
const CAMERA_DISTANCE_MAX = 24;
|
const FIXED_CAMERA_TARGET = new THREE.Vector3(0, 2.35, 0);
|
||||||
|
const MODEL_FRONT_ROTATION_Y = -Math.PI / 2;
|
||||||
|
const MODEL_UPRIGHT_ROTATION_Z =
|
||||||
|
Math.PI * 1.5 - THREE.MathUtils.degToRad(2.2);
|
||||||
|
const sensorRegions: SensorRegion[] = [
|
||||||
|
{
|
||||||
|
id: "thumb",
|
||||||
|
rows: 12,
|
||||||
|
cols: 7,
|
||||||
|
x: -1.38,
|
||||||
|
y: 1.98,
|
||||||
|
width: 0.52,
|
||||||
|
height: 0.98,
|
||||||
|
rotation: THREE.MathUtils.degToRad(35),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "index",
|
||||||
|
rows: 12,
|
||||||
|
cols: 7,
|
||||||
|
x: -0.6,
|
||||||
|
y: 3.75,
|
||||||
|
width: 0.52,
|
||||||
|
height: 0.98,
|
||||||
|
rotation: THREE.MathUtils.degToRad(6),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "middle",
|
||||||
|
rows: 12,
|
||||||
|
cols: 7,
|
||||||
|
x: 0.1,
|
||||||
|
y: 4.0,
|
||||||
|
width: 0.58,
|
||||||
|
height: 1.06,
|
||||||
|
rotation: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ring",
|
||||||
|
rows: 12,
|
||||||
|
cols: 7,
|
||||||
|
x: 0.82,
|
||||||
|
y: 3.9,
|
||||||
|
width: 0.52,
|
||||||
|
height: 0.96,
|
||||||
|
rotation: THREE.MathUtils.degToRad(-5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pinky",
|
||||||
|
rows: 12,
|
||||||
|
cols: 7,
|
||||||
|
x: 1.42,
|
||||||
|
y: 3.2,
|
||||||
|
width: 0.46,
|
||||||
|
height: 0.88,
|
||||||
|
rotation: THREE.MathUtils.degToRad(-8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "palm-horizontal",
|
||||||
|
rows: 5,
|
||||||
|
cols: 14,
|
||||||
|
x: 0.26,
|
||||||
|
y: 1.68,
|
||||||
|
width: 1.84,
|
||||||
|
height: 0.5,
|
||||||
|
rotation: THREE.MathUtils.degToRad(-4),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "palm-vertical",
|
||||||
|
rows: 11,
|
||||||
|
cols: 4,
|
||||||
|
x: 0.86,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.42,
|
||||||
|
height: 1.26,
|
||||||
|
rotation: THREE.MathUtils.degToRad(-4),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
$: copy =
|
$: canvasLabel = locale === "zh-CN" ? "3D 模型" : "3D Model";
|
||||||
locale === "zh-CN"
|
|
||||||
? {
|
$: updateSensorColors();
|
||||||
title: "3D 模型舱",
|
|
||||||
subtitle: "Dark Grid / Future Lab",
|
let sensorGeometry: THREE.BufferGeometry | null = null;
|
||||||
loading: "正在加载模型",
|
let sensorColors: Float32Array | null = null;
|
||||||
ready: "模型已载入",
|
let sensorPointCount = 0;
|
||||||
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) => {
|
||||||
@@ -86,14 +153,18 @@
|
|||||||
color: 0x0c1824,
|
color: 0x0c1824,
|
||||||
emissive: 0x07131f,
|
emissive: 0x07131f,
|
||||||
metalness: 0.62,
|
metalness: 0.62,
|
||||||
roughness: 0.34
|
roughness: 0.34,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
platform.position.y = 0.18;
|
platform.position.y = 0.18;
|
||||||
group.add(platform);
|
group.add(platform);
|
||||||
|
|
||||||
const ringGeometry = new THREE.TorusGeometry(4.35, 0.035, 10, 128);
|
const ringGeometry = new THREE.TorusGeometry(4.35, 0.035, 10, 128);
|
||||||
const ringMaterial = new THREE.MeshBasicMaterial({ color: cyan, transparent: true, opacity: 0.78 });
|
const ringMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: cyan,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.78,
|
||||||
|
});
|
||||||
for (let index = 0; index < 3; index += 1) {
|
for (let index = 0; index < 3; index += 1) {
|
||||||
const ring = new THREE.Mesh(ringGeometry, ringMaterial);
|
const ring = new THREE.Mesh(ringGeometry, ringMaterial);
|
||||||
ring.position.y = 0.52 + index * 0.52;
|
ring.position.y = 0.52 + index * 0.52;
|
||||||
@@ -107,17 +178,31 @@
|
|||||||
metalness: 0.48,
|
metalness: 0.48,
|
||||||
roughness: 0.42,
|
roughness: 0.42,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.72
|
opacity: 0.72,
|
||||||
});
|
});
|
||||||
const core = new THREE.Mesh(new THREE.BoxGeometry(2.2, 3.4, 1.1), coreMaterial);
|
const core = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(2.2, 3.4, 1.1),
|
||||||
|
coreMaterial,
|
||||||
|
);
|
||||||
core.position.y = 2.4;
|
core.position.y = 2.4;
|
||||||
core.rotation.y = -0.36;
|
core.rotation.y = -0.36;
|
||||||
group.add(core);
|
group.add(core);
|
||||||
|
|
||||||
const sensorMaterial = new THREE.MeshBasicMaterial({ color: lime, transparent: true, opacity: 0.88 });
|
const sensorMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
color: lime,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.88,
|
||||||
|
});
|
||||||
for (let index = 0; index < 7; index += 1) {
|
for (let index = 0; index < 7; index += 1) {
|
||||||
const bead = new THREE.Mesh(new THREE.SphereGeometry(0.13, 18, 18), sensorMaterial);
|
const bead = new THREE.Mesh(
|
||||||
bead.position.set(-0.72 + index * 0.24, 3.18 + Math.sin(index * 0.72) * 0.18, 0.6);
|
new THREE.SphereGeometry(0.13, 18, 18),
|
||||||
|
sensorMaterial,
|
||||||
|
);
|
||||||
|
bead.position.set(
|
||||||
|
-0.72 + index * 0.24,
|
||||||
|
3.18 + Math.sin(index * 0.72) * 0.18,
|
||||||
|
0.6,
|
||||||
|
);
|
||||||
group.add(bead);
|
group.add(bead);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,12 +213,144 @@
|
|||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mixColors(
|
||||||
|
low: THREE.Color,
|
||||||
|
high: THREE.Color,
|
||||||
|
amount: number,
|
||||||
|
): THREE.Color {
|
||||||
|
return low.clone().lerp(high, clamp(amount, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSensorOverlay(): THREE.Points {
|
||||||
|
const positions: number[] = [];
|
||||||
|
|
||||||
|
for (const region of sensorRegions) {
|
||||||
|
const sin = Math.sin(region.rotation);
|
||||||
|
const cos = Math.cos(region.rotation);
|
||||||
|
const colSpan = Math.max(region.cols - 1, 1);
|
||||||
|
const rowSpan = Math.max(region.rows - 1, 1);
|
||||||
|
|
||||||
|
for (let row = 0; row < region.rows; row += 1) {
|
||||||
|
for (let col = 0; col < region.cols; col += 1) {
|
||||||
|
const localX = (col / colSpan - 0.5) * region.width;
|
||||||
|
const localY = (0.5 - row / rowSpan) * region.height;
|
||||||
|
const x = region.x + localX * cos - localY * sin;
|
||||||
|
const y = region.y + localX * sin + localY * cos;
|
||||||
|
|
||||||
|
positions.push(x, y, SENSOR_Z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sensorPointCount = positions.length / 3;
|
||||||
|
sensorColors = new Float32Array(sensorPointCount * 3);
|
||||||
|
sensorGeometry = new THREE.BufferGeometry();
|
||||||
|
sensorGeometry.setAttribute(
|
||||||
|
"position",
|
||||||
|
new THREE.Float32BufferAttribute(positions, 3),
|
||||||
|
);
|
||||||
|
sensorGeometry.setAttribute(
|
||||||
|
"color",
|
||||||
|
new THREE.BufferAttribute(sensorColors, 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
const points = new THREE.Points(
|
||||||
|
sensorGeometry,
|
||||||
|
new THREE.PointsMaterial({
|
||||||
|
size: 0.052,
|
||||||
|
vertexColors: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.92,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
sizeAttenuation: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
points.renderOrder = 20;
|
||||||
|
points.name = "hand-sensor-dot-overlay";
|
||||||
|
updateSensorColors();
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSensorColors(): void {
|
||||||
|
if (!sensorGeometry || !sensorColors || sensorPointCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const palette =
|
||||||
|
pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald;
|
||||||
|
const base = new THREE.Color(palette.labelZero);
|
||||||
|
const mid = new THREE.Color(palette.surfaceHigh);
|
||||||
|
const hot = new THREE.Color(palette.surfaceHot);
|
||||||
|
const span = Math.max(rangeMax - rangeMin, 1);
|
||||||
|
const dimFactor = pressureMatrix ? 0.9 : 0.42;
|
||||||
|
|
||||||
|
for (let index = 0; index < sensorPointCount; index += 1) {
|
||||||
|
const value = pressureMatrix?.[index];
|
||||||
|
const normalized = Number.isFinite(value)
|
||||||
|
? clamp((Number(value) - rangeMin) / span, 0, 1)
|
||||||
|
: 0;
|
||||||
|
const color =
|
||||||
|
normalized <= 0.5
|
||||||
|
? mixColors(base, mid, normalized * 2)
|
||||||
|
: mixColors(mid, hot, (normalized - 0.5) * 2);
|
||||||
|
|
||||||
|
sensorColors[index * 3] = color.r * dimFactor;
|
||||||
|
sensorColors[index * 3 + 1] = color.g * dimFactor;
|
||||||
|
sensorColors[index * 3 + 2] = color.b * dimFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorAttribute = sensorGeometry.getAttribute("color");
|
||||||
|
if (colorAttribute) {
|
||||||
|
colorAttribute.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
const currentHeight = Math.max(size.y, 0.001);
|
const currentHeight = Math.max(size.y, 0.001);
|
||||||
const scale = clamp(MODEL_TARGET_HEIGHT / currentHeight, MODEL_MIN_SCALE, MODEL_MAX_SCALE);
|
const scale = clamp(
|
||||||
|
MODEL_TARGET_HEIGHT / currentHeight,
|
||||||
|
MODEL_MIN_SCALE,
|
||||||
|
MODEL_MAX_SCALE,
|
||||||
|
);
|
||||||
|
|
||||||
object.scale.multiplyScalar(scale);
|
object.scale.multiplyScalar(scale);
|
||||||
object.updateMatrixWorld(true);
|
object.updateMatrixWorld(true);
|
||||||
@@ -148,20 +365,19 @@
|
|||||||
return new THREE.Box3().setFromObject(object);
|
return new THREE.Box3().setFromObject(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
function frameObject(object: THREE.Object3D, camera: THREE.PerspectiveCamera, controls: OrbitControls): void {
|
function frameObject(
|
||||||
const bounds = normalizeObjectToStage(object);
|
object: THREE.Object3D,
|
||||||
const size = bounds.getSize(new THREE.Vector3());
|
camera: THREE.PerspectiveCamera,
|
||||||
const maxAxis = Math.max(size.x, size.y, size.z, 1);
|
controls: OrbitControls,
|
||||||
const distance = clamp(maxAxis * CAMERA_DISTANCE_FACTOR, CAMERA_DISTANCE_MIN, CAMERA_DISTANCE_MAX);
|
): void {
|
||||||
const targetY = FLOOR_Y + Math.max(size.y * 0.46, 1.4);
|
normalizeObjectToStage(object);
|
||||||
|
camera.position.copy(FIXED_CAMERA_POSITION);
|
||||||
camera.position.set(distance * 0.48, targetY + distance * 0.24, distance * 0.68);
|
camera.near = 0.05;
|
||||||
camera.near = Math.max(distance / 80, 0.01);
|
camera.far = 600;
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +390,7 @@
|
|||||||
canvas: canvasEl,
|
canvas: canvasEl,
|
||||||
antialias: true,
|
antialias: true,
|
||||||
alpha: true,
|
alpha: true,
|
||||||
powerPreference: "high-performance"
|
powerPreference: "high-performance",
|
||||||
});
|
});
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
renderer.setClearColor(0x03070d, 1);
|
renderer.setClearColor(0x03070d, 1);
|
||||||
@@ -186,14 +402,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);
|
||||||
@@ -234,25 +451,19 @@
|
|||||||
metalness: 0.28,
|
metalness: 0.28,
|
||||||
roughness: 0.64,
|
roughness: 0.64,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.72
|
opacity: 0.72,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
floor.rotation.x = -Math.PI / 2;
|
floor.rotation.x = -Math.PI / 2;
|
||||||
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);
|
||||||
|
const sensorOverlay = buildSensorOverlay();
|
||||||
|
scene.add(sensorOverlay);
|
||||||
|
|
||||||
const loader = new GLTFLoader();
|
const loader = new GLTFLoader();
|
||||||
loader.load(
|
loader.load(
|
||||||
@@ -261,13 +472,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";
|
||||||
@@ -285,7 +490,7 @@
|
|||||||
loadError = message || "Unknown model loader error";
|
loadError = message || "Unknown model loader error";
|
||||||
loadState = message.toLowerCase().includes("404") ? "missing" : "error";
|
loadState = message.toLowerCase().includes("404") ? "missing" : "error";
|
||||||
loadProgress = 0;
|
loadProgress = 0;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
@@ -323,6 +528,7 @@
|
|||||||
renderer.setAnimationLoop(null);
|
renderer.setAnimationLoop(null);
|
||||||
controls.dispose();
|
controls.dispose();
|
||||||
disposeObject3D(activeModel);
|
disposeObject3D(activeModel);
|
||||||
|
disposeObject3D(sensorOverlay);
|
||||||
disposeObject3D(labGroup);
|
disposeObject3D(labGroup);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
};
|
};
|
||||||
@@ -330,20 +536,10 @@
|
|||||||
</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>
|
||||||
@@ -351,9 +547,16 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background: radial-gradient(
|
||||||
radial-gradient(circle at 52% 62%, rgb(94 231 255 / 0.12), transparent 26%),
|
circle at 52% 62%,
|
||||||
radial-gradient(circle at 24% 18%, rgb(166 255 122 / 0.07), transparent 24%),
|
rgb(94 231 255 / 0.12),
|
||||||
|
transparent 26%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 24% 18%,
|
||||||
|
rgb(166 255 122 / 0.07),
|
||||||
|
transparent 24%
|
||||||
|
),
|
||||||
linear-gradient(180deg, #03070d 0%, #07111b 48%, #02050a 100%);
|
linear-gradient(180deg, #03070d 0%, #07111b 48%, #02050a 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,94 +579,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.model-vignette {
|
.model-vignette {
|
||||||
background:
|
background: linear-gradient(
|
||||||
linear-gradient(90deg, rgb(0 0 0 / 0.36), transparent 22%, transparent 78%, rgb(0 0 0 / 0.34)),
|
90deg,
|
||||||
|
rgb(0 0 0 / 0.36),
|
||||||
|
transparent 22%,
|
||||||
|
transparent 78%,
|
||||||
|
rgb(0 0 0 / 0.34)
|
||||||
|
),
|
||||||
radial-gradient(circle at center, transparent 48%, rgb(0 0 0 / 0.58) 100%);
|
radial-gradient(circle at center, transparent 48%, rgb(0 0 0 / 0.58) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-scanlines {
|
.model-scanlines {
|
||||||
opacity: 0.32;
|
opacity: 0.32;
|
||||||
background:
|
background: repeating-linear-gradient(
|
||||||
repeating-linear-gradient(180deg, rgb(94 231 255 / 0.045) 0, rgb(94 231 255 / 0.045) 1px, transparent 1px, transparent 4px);
|
180deg,
|
||||||
|
rgb(94 231 255 / 0.045) 0,
|
||||||
|
rgb(94 231 255 / 0.045) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 4px
|
||||||
|
);
|
||||||
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>
|
||||||
|
|||||||
@@ -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,49 @@
|
|||||||
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 unlistenHandGatewayPressureMatrix: 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 +1874,42 @@
|
|||||||
.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<number[]>("hand_gateway_pressure_matrix", (event) => {
|
||||||
|
if (replayHasData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pressureMatrix = event.payload;
|
||||||
|
hasSignalData = event.payload.length > 0;
|
||||||
|
})
|
||||||
|
.then((unlisten) => {
|
||||||
|
if (disposed) {
|
||||||
|
unlisten();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlistenHandGatewayPressureMatrix = unlisten;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to listen for hand_gateway_pressure_matrix:", 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 +1950,8 @@
|
|||||||
clearDevkitSpatialForce();
|
clearDevkitSpatialForce();
|
||||||
stopMockFeed?.();
|
stopMockFeed?.();
|
||||||
unlistenHudStream?.();
|
unlistenHudStream?.();
|
||||||
|
unlistenHandGatewayRaw?.();
|
||||||
|
unlistenHandGatewayPressureMatrix?.();
|
||||||
unlistenDevkitPztAngle?.();
|
unlistenDevkitPztAngle?.();
|
||||||
if (devkitStatusTimer != null) {
|
if (devkitStatusTimer != null) {
|
||||||
window.clearInterval(devkitStatusTimer);
|
window.clearInterval(devkitStatusTimer);
|
||||||
|
|||||||
BIN
static/models/je-skin-model.bin
Normal file
BIN
static/models/je-skin-model.bin
Normal file
Binary file not shown.
Binary file not shown.
1
static/models/je-skin-model.gltf
Normal file
1
static/models/je-skin-model.gltf
Normal file
File diff suppressed because one or more lines are too long
18
tests/example.spec.ts
Normal file
18
tests/example.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('has title', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
|
||||||
|
// Expect a title "to contain" a substring.
|
||||||
|
await expect(page).toHaveTitle(/Playwright/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get started link', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
|
||||||
|
// Click the get started link.
|
||||||
|
await page.getByRole('link', { name: 'Get started' }).click();
|
||||||
|
|
||||||
|
// Expects page to have a heading with the name of Installation.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
|||||||
Reference in New Issue
Block a user