6 Commits

Author SHA1 Message Date
Ubuntu
39840f469f feat: customer demo - remove breakout game, numeric matrix, 3D module switcher
- Remove NeonBreakoutArena split panel from CenterStage
- Remove numeric matrix display mode toggle (keep dot matrix only)
- Remove 3D module stage mode switcher from HudPanel
- Remove precision-test config link and related UI state
- Cargo check passes (1 warning: unused import in log.rs)
2026-05-25 15:17:48 +08:00
lenn
b581e310ed update frontend components and cargo config 2026-05-20 10:18:27 +08:00
lenn
c579544351 Revert "fix: compensate tangential force edge and full-surface cases"
This reverts commit aa08a75aef.
2026-05-20 09:45:47 +08:00
lenn
aa08a75aef fix: compensate tangential force edge and full-surface cases 2026-05-20 09:39:14 +08:00
lenn
6187976b6b feat: integrate tangential force HUD 2026-05-20 08:33:20 +08:00
lenn
59e9203363 feat: add 3D model viewer, HUD panel updates, and eskin-finger-sdk submodule
- Add ModelStage component for 3D model rendering
- Update CenterStage to integrate ModelStage viewer
- Expand HudPanel with new functionality
- Add HUD type definitions
- Add je-skin-model.glb 3D model asset
- Add eskin-finger-sdk as git submodule
2026-05-18 23:37:01 +08:00
43 changed files with 5145 additions and 1287 deletions

6
.gitignore vendored
View File

@@ -25,6 +25,12 @@ vite.config.ts.timestamp-*
/src-tauri/target/ /src-tauri/target/
/src-tauri/target-codex-check*/ /src-tauri/target-codex-check*/
/src-tauri/gen/schemas/ /src-tauri/gen/schemas/
/src-tauri/gen/android/app/build/
/src-tauri/gen/android/buildSrc/build/
/src-tauri/gen/android/.gradle/
/src-tauri/gen/android/app/.gradle/
/src-tauri/gen/android/buildSrc/.gradle/
/src-tauri/gen/android/build/reports/
/src-tauri/program.log* /src-tauri/program.log*
/src-tauri/recording_replay_debug_*.csv /src-tauri/recording_replay_debug_*.csv

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "eskin-finger-sdk"]
path = eskin-finger-sdk
url = https://gitea.e-skin.top/yanjie/eskin-finger-sdk.git

2
package-lock.json generated
View File

@@ -6,7 +6,7 @@
"packages": { "packages": {
"": { "": {
"name": "JE-Skin", "name": "JE-Skin",
"version": "0.4.0", "version": "0.3.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",

1388
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,9 @@ name = "tauri_demo_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[features] [features]
default = [] default = ["multi-dim"]
devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"] devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"]
multi-dim = ["dep:ndarray"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
@@ -36,19 +37,22 @@ async-stream = { version = "0.3", optional = true }
dirs = { version = "6", optional = true } dirs = { version = "6", optional = true }
tokio-serial = { version = "5.4.5" } tokio-serial = { version = "5.4.5" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
async-trait = "0.1.89"
tokio-util = "0.7.18" tokio-util = "0.7.18"
serde_json = "1" serde_json = "1"
fern = { version = "0.7.1", features = ["colored", "date-based"] } fern = { version = "0.7.1", features = ["colored", "date-based"] }
log = "0.4.29" log = "0.4.29"
humantime = "2.3.0" humantime = "2.3.0"
csv = "1.4.0" csv = "1.4.0"
chrono = "0.4.44"
crc = "3.4.0"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] } tower-http = { version = "0.6", features = ["cors"] }
futures-util = "0.3" futures-util = "0.3"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
rand = "0.8" rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
eskin-finger-sdk = { path = "../eskin-finger-sdk" } ndarray = { version = "0.15", optional = true }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"

View File

@@ -0,0 +1,5 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
-keep class com.lenn.tauri_serial.TauriActivity {
public app.tauri.plugin.PluginManager getPluginManager();
}

View File

@@ -0,0 +1 @@
{"$schema":"https://schema.tauri.app/config/2","productName":"JE-Skin","version":"0.4.0","identifier":"com.lenn.tauri-serial","app":{"windows":[{"label":"main","create":true,"url":"index.html","dragDropEnabled":true,"center":false,"width":1366.0,"height":860.0,"resizable":true,"maximizable":true,"minimizable":true,"closable":true,"title":"JE-Skin","fullscreen":false,"focus":true,"focusable":true,"transparent":false,"maximized":false,"visible":true,"decorations":false,"alwaysOnBottom":false,"alwaysOnTop":false,"visibleOnAllWorkspaces":false,"contentProtected":false,"skipTaskbar":false,"titleBarStyle":"Visible","hiddenTitle":false,"acceptFirstMouse":false,"shadow":true,"incognito":false,"zoomHotkeysEnabled":false,"browserExtensionsEnabled":false,"useHttpsScheme":false,"javascriptDisabled":false,"allowLinkPreview":true,"disableInputAccessoryView":false,"scrollBarStyle":"default"}],"security":{"freezePrototype":false,"dangerousDisableAssetCspModification":false,"assetProtocol":{"scope":[],"enable":false},"pattern":{"use":"brownfield"},"capabilities":[]},"macOSPrivateApi":false,"withGlobalTauri":false,"enableGTKAppId":false},"build":{"devUrl":"http://localhost:1420/","frontendDist":"../build","beforeDevCommand":"npm run dev","beforeBuildCommand":"npm run build","removeUnusedCommands":false,"additionalWatchFolders":[]},"bundle":{"active":true,"targets":"all","createUpdaterArtifacts":true,"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.icns","icons/icon.ico"],"resources":["resources/je-skin-devkit-server.exe"],"useLocalToolsDir":false,"windows":{"digestAlgorithm":null,"certificateThumbprint":null,"timestampUrl":null,"tsp":false,"webviewInstallMode":{"type":"downloadBootstrapper","silent":true},"allowDowngrades":true,"wix":null,"nsis":{"template":"nsis/installer.nsi","headerImage":null,"sidebarImage":null,"installerIcon":"icons/icon.ico","installMode":"both","languages":null,"customLanguageFiles":null,"displayLanguageSelector":false,"compression":"lzma","startMenuFolder":null,"installerHooks":null,"minimumWebview2Version":null},"signCommand":null},"linux":{"appimage":{"bundleMediaFramework":false,"files":{}},"deb":{"files":{}},"rpm":{"release":"1","epoch":0,"files":{}}},"macOS":{"files":{},"minimumSystemVersion":"10.13","hardenedRuntime":true,"dmg":{"windowSize":{"width":660,"height":400},"appPosition":{"x":180,"y":170},"applicationFolderPosition":{"x":480,"y":170}}},"iOS":{"minimumSystemVersion":"14.0"},"android":{"minSdkVersion":24,"autoIncrementVersionCode":false}},"plugins":{}}

View File

@@ -0,0 +1,33 @@
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
// Copyright 2020-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
@file:Suppress("unused")
package com.lenn.tauri_serial
import android.webkit.*
class Ipc(val webViewClient: RustWebViewClient) {
@JavascriptInterface
fun postMessage(message: String?) {
message?.let {m ->
// we're not using WebView::getUrl() here because it needs to be executed on the main thread
// and it would slow down the Ipc
// so instead we track the current URL on the webview client
this.ipc(webViewClient.currentUrl, m)
}
}
companion object {
init {
System.loadLibrary("tauri_demo_lib")
}
}
private external fun ipc(url: String, message: String)
}

View File

@@ -0,0 +1,89 @@
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
// Copyright 2020-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
@file:Suppress("unused", "MemberVisibilityCanBePrivate")
package com.lenn.tauri_serial
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/Logger.java
import android.text.TextUtils
import android.util.Log
class Logger {
companion object {
private const val LOG_TAG_CORE = "Tauri"
fun tags(vararg subtags: String): String {
return if (subtags.isNotEmpty()) {
LOG_TAG_CORE + "/" + TextUtils.join("/", subtags)
} else LOG_TAG_CORE
}
fun verbose(message: String) {
verbose(LOG_TAG_CORE, message)
}
private fun verbose(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.v(tag, message)
}
fun debug(message: String) {
debug(LOG_TAG_CORE, message)
}
fun debug(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.d(tag, message)
}
fun info(message: String) {
info(LOG_TAG_CORE, message)
}
fun info(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.i(tag, message)
}
fun warn(message: String) {
warn(LOG_TAG_CORE, message)
}
fun warn(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.w(tag, message)
}
fun error(message: String) {
error(LOG_TAG_CORE, message, null)
}
fun error(message: String, e: Throwable?) {
error(LOG_TAG_CORE, message, e)
}
fun error(tag: String, message: String, e: Throwable?) {
if (!shouldLog()) {
return
}
Log.e(tag, message, e)
}
private fun shouldLog(): Boolean {
return BuildConfig.DEBUG
}
}
}

View File

@@ -0,0 +1,117 @@
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
// Copyright 2020-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package com.lenn.tauri_serial
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/PermissionHelper.java
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import java.util.ArrayList
object PermissionHelper {
/**
* Checks if a list of given permissions are all granted by the user
*
* @param permissions Permissions to check.
* @return True if all permissions are granted, false if at least one is not.
*/
fun hasPermissions(context: Context?, permissions: Array<String>): Boolean {
for (perm in permissions) {
if (ActivityCompat.checkSelfPermission(
context!!,
perm
) != PackageManager.PERMISSION_GRANTED
) {
return false
}
}
return true
}
/**
* Check whether the given permission has been defined in the AndroidManifest.xml
*
* @param permission A permission to check.
* @return True if the permission has been defined in the Manifest, false if not.
*/
fun hasDefinedPermission(context: Context, permission: String): Boolean {
var hasPermission = false
val requestedPermissions = getManifestPermissions(context)
if (!requestedPermissions.isNullOrEmpty()) {
val requestedPermissionsList = listOf(*requestedPermissions)
val requestedPermissionsArrayList = ArrayList(requestedPermissionsList)
if (requestedPermissionsArrayList.contains(permission)) {
hasPermission = true
}
}
return hasPermission
}
/**
* Check whether all of the given permissions have been defined in the AndroidManifest.xml
* @param context the app context
* @param permissions a list of permissions
* @return true only if all permissions are defined in the AndroidManifest.xml
*/
fun hasDefinedPermissions(context: Context, permissions: Array<String>): Boolean {
for (permission in permissions) {
if (!hasDefinedPermission(context, permission)) {
return false
}
}
return true
}
/**
* Get the permissions defined in AndroidManifest.xml
*
* @return The permissions defined in AndroidManifest.xml
*/
private fun getManifestPermissions(context: Context): Array<String>? {
var requestedPermissions: Array<String>? = null
try {
val pm = context.packageManager
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()))
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
}
if (packageInfo != null) {
requestedPermissions = packageInfo.requestedPermissions
}
} catch (_: Exception) {
}
return requestedPermissions
}
/**
* Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml
*
* @param neededPermissions The permissions needed.
* @return The permissions not present in AndroidManifest.xml
*/
fun getUndefinedPermissions(context: Context, neededPermissions: Array<String?>): Array<String?> {
val undefinedPermissions = ArrayList<String?>()
val requestedPermissions = getManifestPermissions(context)
if (!requestedPermissions.isNullOrEmpty()) {
val requestedPermissionsList = listOf(*requestedPermissions)
val requestedPermissionsArrayList = ArrayList(requestedPermissionsList)
for (permission in neededPermissions) {
if (!requestedPermissionsArrayList.contains(permission)) {
undefinedPermissions.add(permission)
}
}
var undefinedPermissionArray = arrayOfNulls<String>(undefinedPermissions.size)
undefinedPermissionArray = undefinedPermissions.toArray(undefinedPermissionArray)
return undefinedPermissionArray
}
return neededPermissions
}
}

View File

@@ -0,0 +1,495 @@
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
// Copyright 2020-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
@file:Suppress("ObsoleteSdkInt", "RedundantOverride", "QueryPermissionsNeeded", "SimpleDateFormat")
package com.lenn.tauri_serial
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java
import android.Manifest
import android.app.Activity
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.view.View
import android.webkit.*
import android.widget.EditText
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
class RustWebChromeClient(appActivity: WryActivity) : WebChromeClient() {
private interface PermissionListener {
fun onPermissionSelect(isGranted: Boolean?)
}
private interface ActivityResultListener {
fun onActivityResult(result: ActivityResult?)
}
private val activity: WryActivity
private var permissionLauncher: ActivityResultLauncher<Array<String>>
private var activityLauncher: ActivityResultLauncher<Intent>
private var permissionListener: PermissionListener? = null
private var activityListener: ActivityResultListener? = null
init {
activity = appActivity
val permissionCallback =
ActivityResultCallback { isGranted: Map<String, Boolean> ->
if (permissionListener != null) {
var granted = true
for ((_, value) in isGranted) {
if (!value) granted = false
}
permissionListener!!.onPermissionSelect(granted)
}
}
permissionLauncher =
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), permissionCallback)
activityLauncher = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (activityListener != null) {
activityListener!!.onActivityResult(result)
}
}
}
/**
* Render web content in `view`.
*
* Both this method and [.onHideCustomView] are required for
* rendering web content in full screen.
*
* @see [](https://developer.android.com/reference/android/webkit/WebChromeClient.onShowCustomView
) */
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
callback.onCustomViewHidden()
super.onShowCustomView(view, callback)
}
/**
* Render web content in the original Web View again.
*
* Do not remove this method--@see #onShowCustomView(View, CustomViewCallback).
*/
override fun onHideCustomView() {
super.onHideCustomView()
}
override fun onPermissionRequest(request: PermissionRequest) {
val isRequestPermissionRequired = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
val permissionList: MutableList<String> = ArrayList()
if (listOf(*request.resources).contains("android.webkit.resource.VIDEO_CAPTURE")) {
permissionList.add(Manifest.permission.CAMERA)
}
if (listOf(*request.resources).contains("android.webkit.resource.AUDIO_CAPTURE")) {
permissionList.add(Manifest.permission.MODIFY_AUDIO_SETTINGS)
permissionList.add(Manifest.permission.RECORD_AUDIO)
}
if (permissionList.isNotEmpty() && isRequestPermissionRequired) {
val permissions = permissionList.toTypedArray()
permissionListener = object : PermissionListener {
override fun onPermissionSelect(isGranted: Boolean?) {
if (isGranted == true) {
request.grant(request.resources)
} else {
request.deny()
}
}
}
permissionLauncher.launch(permissions)
} else {
request.grant(request.resources)
}
}
/**
* Show the browser alert modal
* @param view
* @param url
* @param message
* @param result
* @return
*/
override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean {
if (activity.isFinishing) {
return true
}
val builder = AlertDialog.Builder(view.context)
builder
.setMessage(message)
.setPositiveButton(
"OK"
) { dialog: DialogInterface, _: Int ->
dialog.dismiss()
result.confirm()
}
.setOnCancelListener { dialog: DialogInterface ->
dialog.dismiss()
result.cancel()
}
val dialog = builder.create()
dialog.show()
return true
}
/**
* Show the browser confirm modal
* @param view
* @param url
* @param message
* @param result
* @return
*/
override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): Boolean {
if (activity.isFinishing) {
return true
}
val builder = AlertDialog.Builder(view.context)
builder
.setMessage(message)
.setPositiveButton(
"OK"
) { dialog: DialogInterface, _: Int ->
dialog.dismiss()
result.confirm()
}
.setNegativeButton(
"Cancel"
) { dialog: DialogInterface, _: Int ->
dialog.dismiss()
result.cancel()
}
.setOnCancelListener { dialog: DialogInterface ->
dialog.dismiss()
result.cancel()
}
val dialog = builder.create()
dialog.show()
return true
}
/**
* Show the browser prompt modal
* @param view
* @param url
* @param message
* @param defaultValue
* @param result
* @return
*/
override fun onJsPrompt(
view: WebView,
url: String,
message: String,
defaultValue: String,
result: JsPromptResult
): Boolean {
if (activity.isFinishing) {
return true
}
val builder = AlertDialog.Builder(view.context)
val input = EditText(view.context)
builder
.setMessage(message)
.setView(input)
.setPositiveButton(
"OK"
) { dialog: DialogInterface, _: Int ->
dialog.dismiss()
val inputText1 = input.text.toString().trim { it <= ' ' }
result.confirm(inputText1)
}
.setNegativeButton(
"Cancel"
) { dialog: DialogInterface, _: Int ->
dialog.dismiss()
result.cancel()
}
.setOnCancelListener { dialog: DialogInterface ->
dialog.dismiss()
result.cancel()
}
val dialog = builder.create()
dialog.show()
return true
}
/**
* Handle the browser geolocation permission prompt
* @param origin
* @param callback
*/
override fun onGeolocationPermissionsShowPrompt(
origin: String,
callback: GeolocationPermissions.Callback
) {
super.onGeolocationPermissionsShowPrompt(origin, callback)
Logger.debug("onGeolocationPermissionsShowPrompt: DOING IT HERE FOR ORIGIN: $origin")
val geoPermissions =
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
if (!PermissionHelper.hasPermissions(activity, geoPermissions)) {
permissionListener = object : PermissionListener {
override fun onPermissionSelect(isGranted: Boolean?) {
if (isGranted == true) {
callback.invoke(origin, true, false)
} else {
val coarsePermission =
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
PermissionHelper.hasPermissions(activity, coarsePermission)
) {
callback.invoke(origin, true, false)
} else {
callback.invoke(origin, false, false)
}
}
}
}
permissionLauncher.launch(geoPermissions)
} else {
// permission is already granted
callback.invoke(origin, true, false)
Logger.debug("onGeolocationPermissionsShowPrompt: has required permission")
}
}
override fun onShowFileChooser(
webView: WebView,
filePathCallback: ValueCallback<Array<Uri?>?>,
fileChooserParams: FileChooserParams
): Boolean {
val acceptTypes = listOf(*fileChooserParams.acceptTypes)
val captureEnabled = fileChooserParams.isCaptureEnabled
val capturePhoto = captureEnabled && acceptTypes.contains("image/*")
val captureVideo = captureEnabled && acceptTypes.contains("video/*")
if (capturePhoto || captureVideo) {
if (isMediaCaptureSupported) {
showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo)
} else {
permissionListener = object : PermissionListener {
override fun onPermissionSelect(isGranted: Boolean?) {
if (isGranted == true) {
showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo)
} else {
Logger.warn(Logger.tags("FileChooser"), "Camera permission not granted")
filePathCallback.onReceiveValue(null)
}
}
}
val camPermission = arrayOf(Manifest.permission.CAMERA)
permissionLauncher.launch(camPermission)
}
} else {
showFilePicker(filePathCallback, fileChooserParams)
}
return true
}
private val isMediaCaptureSupported: Boolean
get() {
val permissions = arrayOf(Manifest.permission.CAMERA)
return PermissionHelper.hasPermissions(activity, permissions) ||
!PermissionHelper.hasDefinedPermission(activity, Manifest.permission.CAMERA)
}
private fun showMediaCaptureOrFilePicker(
filePathCallback: ValueCallback<Array<Uri?>?>,
fileChooserParams: FileChooserParams,
isVideo: Boolean
) {
val isVideoCaptureSupported = true
val shown = if (isVideo && isVideoCaptureSupported) {
showVideoCapturePicker(filePathCallback)
} else {
showImageCapturePicker(filePathCallback)
}
if (!shown) {
Logger.warn(
Logger.tags("FileChooser"),
"Media capture intent could not be launched. Falling back to default file picker."
)
showFilePicker(filePathCallback, fileChooserParams)
}
}
private fun showImageCapturePicker(filePathCallback: ValueCallback<Array<Uri?>?>): Boolean {
val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
if (takePictureIntent.resolveActivity(activity.packageManager) == null) {
return false
}
val imageFileUri: Uri = try {
createImageFileUri()
} catch (ex: Exception) {
Logger.error("Unable to create temporary media capture file: " + ex.message)
return false
}
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri)
activityListener = object : ActivityResultListener {
override fun onActivityResult(result: ActivityResult?) {
var res: Array<Uri?>? = null
if (result?.resultCode == Activity.RESULT_OK) {
res = arrayOf(imageFileUri)
}
filePathCallback.onReceiveValue(res)
}
}
activityLauncher.launch(takePictureIntent)
return true
}
private fun showVideoCapturePicker(filePathCallback: ValueCallback<Array<Uri?>?>): Boolean {
val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE)
if (takeVideoIntent.resolveActivity(activity.packageManager) == null) {
return false
}
activityListener = object : ActivityResultListener {
override fun onActivityResult(result: ActivityResult?) {
var res: Array<Uri?>? = null
if (result?.resultCode == Activity.RESULT_OK) {
res = arrayOf(result.data!!.data)
}
filePathCallback.onReceiveValue(res)
}
}
activityLauncher.launch(takeVideoIntent)
return true
}
private fun showFilePicker(
filePathCallback: ValueCallback<Array<Uri?>?>,
fileChooserParams: FileChooserParams
) {
val intent = fileChooserParams.createIntent()
if (fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
if (fileChooserParams.acceptTypes.size > 1 || intent.type!!.startsWith(".")) {
val validTypes = getValidTypes(fileChooserParams.acceptTypes)
intent.putExtra(Intent.EXTRA_MIME_TYPES, validTypes)
if (intent.type!!.startsWith(".")) {
intent.type = validTypes[0]
}
}
try {
activityListener = object : ActivityResultListener {
override fun onActivityResult(result: ActivityResult?) {
val res: Array<Uri?>?
val resultIntent = result?.data
if (result?.resultCode == Activity.RESULT_OK && resultIntent!!.clipData != null) {
val numFiles = resultIntent.clipData!!.itemCount
res = arrayOfNulls(numFiles)
for (i in 0 until numFiles) {
res[i] = resultIntent.clipData!!.getItemAt(i).uri
}
} else {
res = FileChooserParams.parseResult(
result?.resultCode ?: 0,
resultIntent
)
}
filePathCallback.onReceiveValue(res)
}
}
activityLauncher.launch(intent)
} catch (e: ActivityNotFoundException) {
filePathCallback.onReceiveValue(null)
}
}
private fun getValidTypes(currentTypes: Array<String>): Array<String> {
val validTypes: MutableList<String> = ArrayList()
val mtm = MimeTypeMap.getSingleton()
for (mime in currentTypes) {
if (mime.startsWith(".")) {
val extension = mime.substring(1)
val extensionMime = mtm.getMimeTypeFromExtension(extension)
if (extensionMime != null && !validTypes.contains(extensionMime)) {
validTypes.add(extensionMime)
}
} else if (!validTypes.contains(mime)) {
validTypes.add(mime)
}
}
val validObj: Array<Any> = validTypes.toTypedArray()
return Arrays.copyOf(
validObj, validObj.size,
Array<String>::class.java
)
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
val tag: String = Logger.tags("Console")
if (consoleMessage.message() != null && isValidMsg(consoleMessage.message())) {
val msg = String.format(
"File: %s - Line %d - Msg: %s",
consoleMessage.sourceId(),
consoleMessage.lineNumber(),
consoleMessage.message()
)
val level = consoleMessage.messageLevel().name
if ("ERROR".equals(level, ignoreCase = true)) {
Logger.error(tag, msg, null)
} else if ("WARNING".equals(level, ignoreCase = true)) {
Logger.warn(tag, msg)
} else if ("TIP".equals(level, ignoreCase = true)) {
Logger.debug(tag, msg)
} else {
Logger.info(tag, msg)
}
}
return true
}
private fun isValidMsg(msg: String): Boolean {
return !(msg.contains("%cresult %c") ||
msg.contains("%cnative %c") ||
msg.equals("[object Object]", ignoreCase = true) ||
msg.equals("console.groupEnd", ignoreCase = true))
}
@Throws(IOException::class)
private fun createImageFileUri(): Uri {
val photoFile = createImageFile(activity)
return FileProvider.getUriForFile(
activity,
activity.packageName.toString() + ".fileprovider",
photoFile
)
}
@Throws(IOException::class)
private fun createImageFile(activity: Activity): File {
// Create an image file name
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
val imageFileName = "JPEG_" + timeStamp + "_"
val storageDir = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile(imageFileName, ".jpg", storageDir)
}
override fun onReceivedTitle(
view: WebView,
title: String
) {
handleReceivedTitle(view, title)
}
private external fun handleReceivedTitle(webview: WebView, title: String)
}

View File

@@ -0,0 +1,101 @@
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
// Copyright 2020-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
@file:Suppress("unused", "SetJavaScriptEnabled")
package com.lenn.tauri_serial
import android.annotation.SuppressLint
import android.webkit.*
import android.content.Context
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import kotlin.collections.Map
@SuppressLint("RestrictedApi")
class RustWebView(context: Context, val initScripts: Array<String>, val id: String): WebView(context) {
val isDocumentStartScriptEnabled: Boolean
init {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.setGeolocationEnabled(true)
settings.databaseEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.javaScriptCanOpenWindowsAutomatically = true
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
isDocumentStartScriptEnabled = true
for (script in initScripts) {
WebViewCompat.addDocumentStartJavaScript(this, script, setOf("*"));
}
} else {
isDocumentStartScriptEnabled = false
}
}
fun loadUrlMainThread(url: String) {
post {
loadUrl(url)
}
}
fun loadUrlMainThread(url: String, additionalHttpHeaders: Map<String, String>) {
post {
loadUrl(url, additionalHttpHeaders)
}
}
override fun loadUrl(url: String) {
if (!shouldOverride(url)) {
super.loadUrl(url);
}
}
override fun loadUrl(url: String, additionalHttpHeaders: Map<String, String>) {
if (!shouldOverride(url)) {
super.loadUrl(url, additionalHttpHeaders);
}
}
fun loadHTMLMainThread(html: String) {
post {
super.loadData(html, "text/html", null)
}
}
fun evalScript(id: Int, script: String) {
post {
super.evaluateJavascript(script) { result ->
onEval(id, result)
}
}
}
fun clearAllBrowsingData() {
try {
super.getContext().deleteDatabase("webviewCache.db")
super.getContext().deleteDatabase("webview.db")
super.clearCache(true)
super.clearHistory()
super.clearFormData()
} catch (ex: Exception) {
Logger.error("Unable to create temporary media capture file: " + ex.message)
}
}
fun getCookies(url: String): String {
val cookieManager = CookieManager.getInstance()
return cookieManager.getCookie(url)
}
private external fun shouldOverride(url: String): Boolean
private external fun onEval(id: Int, result: String)
}

View File

@@ -0,0 +1,107 @@
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
// Copyright 2020-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package com.lenn.tauri_serial
import android.net.Uri
import android.webkit.*
import android.content.Context
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import androidx.webkit.WebViewAssetLoader
class RustWebViewClient(context: Context): WebViewClient() {
private val interceptedState = mutableMapOf<String, Boolean>()
var currentUrl: String = "about:blank"
private var lastInterceptedUrl: Uri? = null
private var pendingUrlRedirect: String? = null
private val assetLoader = WebViewAssetLoader.Builder()
.setDomain(assetLoaderDomain())
.addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(context))
.build()
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
pendingUrlRedirect?.let {
Handler(Looper.getMainLooper()).post {
view.loadUrl(it)
}
pendingUrlRedirect = null
return null
}
lastInterceptedUrl = request.url
return if (withAssetLoader()) {
assetLoader.shouldInterceptRequest(request.url)
} else {
val rustWebview = view as RustWebView;
val response = handleRequest(rustWebview.id, request, rustWebview.isDocumentStartScriptEnabled)
interceptedState[request.url.toString()] = response != null
return response
}
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
return shouldOverride(request.url.toString())
}
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
currentUrl = url
if (interceptedState[url] == false) {
val webView = view as RustWebView
for (script in webView.initScripts) {
view.evaluateJavascript(script, null)
}
}
return onPageLoading(url)
}
override fun onPageFinished(view: WebView, url: String) {
onPageLoaded(url)
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError
) {
// we get a net::ERR_CONNECTION_REFUSED when an external URL redirects to a custom protocol
// e.g. oauth flow, because shouldInterceptRequest is not called on redirects
// so we must force retry here with loadUrl() to get a chance of the custom protocol to kick in
if (error.errorCode == ERROR_CONNECT && request.isForMainFrame && request.url != lastInterceptedUrl) {
// prevent the default error page from showing
view.stopLoading()
// without this initial loadUrl the app is stuck
view.loadUrl(request.url.toString())
// ensure the URL is actually loaded - for some reason there's a race condition and we need to call loadUrl() again later
pendingUrlRedirect = request.url.toString()
} else {
super.onReceivedError(view, request, error)
}
}
companion object {
init {
System.loadLibrary("tauri_demo_lib")
}
}
private external fun assetLoaderDomain(): String
private external fun withAssetLoader(): Boolean
private external fun handleRequest(webviewId: String, request: WebResourceRequest, isDocumentStartScriptEnabled: Boolean): WebResourceResponse?
private external fun shouldOverride(url: String): Boolean
private external fun onPageLoading(url: String)
private external fun onPageLoaded(url: String)
}

View File

@@ -0,0 +1,51 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
package com.lenn.tauri_serial
import android.content.Intent
import android.content.res.Configuration
import app.tauri.plugin.PluginManager
abstract class TauriActivity : WryActivity() {
var pluginManager: PluginManager = PluginManager(this)
override val handleBackNavigation: Boolean = false
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pluginManager.onNewIntent(intent)
}
override fun onResume() {
super.onResume()
pluginManager.onResume()
}
override fun onPause() {
super.onPause()
pluginManager.onPause()
}
override fun onRestart() {
super.onRestart()
pluginManager.onRestart()
}
override fun onStop() {
super.onStop()
pluginManager.onStop()
}
override fun onDestroy() {
super.onDestroy()
pluginManager.onDestroy()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
pluginManager.onConfigurationChanged(newConfig)
}
}

View File

@@ -0,0 +1,146 @@
/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */
// Copyright 2020-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package com.lenn.tauri_serial
import com.lenn.tauri_serial.RustWebView
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import android.view.KeyEvent
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
abstract class WryActivity : AppCompatActivity() {
private lateinit var mWebView: RustWebView
open val handleBackNavigation: Boolean = true
open fun onWebViewCreate(webView: WebView) { }
fun setWebView(webView: RustWebView) {
mWebView = webView
if (handleBackNavigation) {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (this@WryActivity.mWebView.canGoBack()) {
this@WryActivity.mWebView.goBack()
} else {
this.isEnabled = false
this@WryActivity.onBackPressed()
this.isEnabled = true
}
}
}
onBackPressedDispatcher.addCallback(this, callback)
}
onWebViewCreate(webView)
}
val version: String
@SuppressLint("WebViewApiAvailability", "ObsoleteSdkInt")
get() {
// Check getCurrentWebViewPackage() directly if above Android 8
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
return WebView.getCurrentWebViewPackage()?.versionName ?: ""
}
// Otherwise manually check WebView versions
var webViewPackage = "com.google.android.webview"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
webViewPackage = "com.android.chrome"
}
try {
@Suppress("DEPRECATION")
val info = packageManager.getPackageInfo(webViewPackage, 0)
return info.versionName.toString()
} catch (ex: Exception) {
Logger.warn("Unable to get package info for '$webViewPackage'$ex")
}
try {
@Suppress("DEPRECATION")
val info = packageManager.getPackageInfo("com.android.webview", 0)
return info.versionName.toString()
} catch (ex: Exception) {
Logger.warn("Unable to get package info for 'com.android.webview'$ex")
}
// Could not detect any webview, return empty string
return ""
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
create(this)
}
override fun onStart() {
super.onStart()
start()
}
override fun onResume() {
super.onResume()
resume()
}
override fun onPause() {
super.onPause()
pause()
}
override fun onStop() {
super.onStop()
stop()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
focus(hasFocus)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
save()
}
override fun onDestroy() {
super.onDestroy()
destroy()
onActivityDestroy()
}
override fun onLowMemory() {
super.onLowMemory()
memory()
}
fun getAppClass(name: String): Class<*> {
return Class.forName(name)
}
companion object {
init {
System.loadLibrary("tauri_demo_lib")
}
}
private external fun create(activity: WryActivity)
private external fun start()
private external fun resume()
private external fun pause()
private external fun stop()
private external fun save()
private external fun destroy()
private external fun onActivityDestroy()
private external fun memory()
private external fun focus(focus: Boolean)
}

View File

@@ -0,0 +1,35 @@
# THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!!
# Copyright 2020-2023 Tauri Programme within The Commons Conservancy
# SPDX-License-Identifier: Apache-2.0
# SPDX-License-Identifier: MIT
-keep class com.lenn.tauri_serial.* {
native <methods>;
}
-keep class com.lenn.tauri_serial.WryActivity {
public <init>(...);
void setWebView(com.lenn.tauri_serial.RustWebView);
java.lang.Class getAppClass(...);
java.lang.String getVersion();
}
-keep class com.lenn.tauri_serial.Ipc {
public <init>(...);
@android.webkit.JavascriptInterface public <methods>;
}
-keep class com.lenn.tauri_serial.RustWebView {
public <init>(...);
void loadUrlMainThread(...);
void loadHTMLMainThread(...);
void evalScript(...);
}
-keep class com.lenn.tauri_serial.RustWebChromeClient,com.lenn.tauri_serial.RustWebViewClient {
public <init>(...);
}

View File

@@ -0,0 +1 @@
/home/lenn/Workspace/JE-Skin/src-tauri/target/aarch64-linux-android/release/libtauri_demo_lib.so

View File

@@ -0,0 +1 @@
/home/lenn/Workspace/JE-Skin/src-tauri/target/armv7-linux-androideabi/release/libtauri_demo_lib.so

View File

@@ -0,0 +1 @@
/home/lenn/Workspace/JE-Skin/src-tauri/target/i686-linux-android/release/libtauri_demo_lib.so

View File

@@ -0,0 +1 @@
/home/lenn/Workspace/JE-Skin/src-tauri/target/x86_64-linux-android/release/libtauri_demo_lib.so

View File

@@ -0,0 +1,6 @@
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
val implementation by configurations
dependencies {
implementation(project(":tauri-android"))
implementation(project(":tauri-plugin-opener"))
}

View File

@@ -0,0 +1,3 @@
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
tauri.android.versionName=0.4.0
tauri.android.versionCode=4000

View File

@@ -0,0 +1,5 @@
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
include ':tauri-android'
project(':tauri-android').projectDir = new File("/home/lenn/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.10.3/mobile/android")
include ':tauri-plugin-opener'
project(':tauri-plugin-opener').projectDir = new File("/home/lenn/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-plugin-opener-2.5.3/android")

View File

@@ -1,18 +1,27 @@
use crate::serial_core::codecs::tactile_a::{
export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler,
};
use crate::serial_core::error::SerialError; use crate::serial_core::error::SerialError;
use crate::serial_core::record::{self, FingerRecording}; use crate::serial_core::record::CsvImporter;
use crate::serial_core::serial; use crate::serial_core::serial::{PollMode, TactileAPollRequester};
use eskin_finger_sdk::device::EskinDevice; use crate::serial_core::{serial, TactileARecording};
use log::info; use log::info;
use serde::Serialize; use serde::Serialize;
use std::fs::File;
use std::io::Cursor; use std::io::Cursor;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{async_runtime::JoinHandle, AppHandle, Manager, State}; use tauri::{async_runtime::JoinHandle, AppHandle, Manager, State};
use tokio_serial::available_ports; use tokio_serial::{available_ports, SerialPortBuilderExt};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
type SharedRecording = Arc<Mutex<FingerRecording>>; const DEFAULT_TACTILE_COLS: usize = 7;
const DEFAULT_TACTILE_ROWS: usize = 12;
const DEFAULT_TACTILE_POLL_INTERVAL_MS: u64 = 10;
const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140;
type SharedTactileRecording = Arc<Mutex<TactileARecording>>;
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -58,18 +67,18 @@ struct SerialSession {
port: String, port: String,
cancel: CancellationToken, cancel: CancellationToken,
task: JoinHandle<()>, task: JoinHandle<()>,
current_record: SharedRecording, current_record: SharedTactileRecording,
} }
#[derive(Default)] #[derive(Default)]
pub struct SerialConnectionState { pub struct SerialConnectionState {
session: Mutex<Option<SerialSession>>, session: Mutex<Option<SerialSession>>,
last_record: Mutex<Option<SharedRecording>>, last_record: Mutex<Option<SharedTactileRecording>>,
} }
pub async fn shutdown_active_session( pub async fn shutdown_active_session(
state: &SerialConnectionState, state: &SerialConnectionState,
) -> Result<Option<(String, SharedRecording)>, SerialError> { ) -> Result<Option<(String, 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()
@@ -139,41 +148,62 @@ pub async fn serial_connect(
} }
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
let current_record = Arc::new(Mutex::new(FingerRecording::new())); let current_record = Arc::new(Mutex::new(TactileARecording::new()));
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();
let task_port_name = port_name.clone(); let task_port_name = port_name.clone();
let port = tokio_serial::new(&port_name, 921600)
.open_native_async()
.map_err(|_| SerialError::OpenError)?;
let session_started_at = Instant::now();
let task = tauri::async_runtime::spawn(async move { let task = tauri::async_runtime::spawn(async move {
// Open device using SDK let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS);
let session = match serial::open_device(&task_port_name) { let handler = TactileAHandler;
Ok(s) => s, let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new(
Err(e) => { Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS),
eprintln!("Failed to open device: {e}"); DEFAULT_TACTILE_COLS,
cleanup_session(&task_app, &task_port_name, task_record).await; DEFAULT_TACTILE_ROWS,
return; Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS),
} )));
};
let mut device = session.device; if let Err(error) = serial::run_serial_with_poll(
// Run stream with recording
if let Err(error) = serial::run_stream_with_record(
task_app.clone(), task_app.clone(),
&mut device, port,
task_cancel, codec,
handler,
session_started_at,
task_record.clone(), task_record.clone(),
task_cancel,
poll_mode,
) )
.await .await
{ {
eprintln!("serial task exited with error: {error}"); eprintln!("serial task exited with error: {error}");
} }
// Close device let manager = task_app.state::<SerialConnectionState>();
let _ = device.close(); if let Ok(mut last_record) = manager.last_record.lock() {
*last_record = Some(task_record);
}
cleanup_session(&task_app, &task_port_name, task_record).await; let mut session = match manager.session.lock() {
Ok(session) => session,
Err(_) => return,
};
{
let should_clear = session
.as_ref()
.map(|current| current.port.as_str() == task_port_name.as_str())
.unwrap_or(false);
if should_clear {
session.take();
}
}
}); });
let mut session = state.session.lock().map_err(|_| SerialError::StateError)?; let mut session = state.session.lock().map_err(|_| SerialError::StateError)?;
@@ -197,31 +227,6 @@ pub async fn serial_connect(
}) })
} }
async fn cleanup_session(
app: &AppHandle,
port_name: &str,
record: SharedRecording,
) {
let manager = app.state::<SerialConnectionState>();
if let Ok(mut last_record) = manager.last_record.lock() {
*last_record = Some(record);
}
let mut session = match manager.session.lock() {
Ok(session) => session,
Err(_) => return,
};
let should_clear = session
.as_ref()
.map(|current| current.port.as_str() == port_name)
.unwrap_or(false);
if should_clear {
session.take();
}
}
#[tauri::command] #[tauri::command]
pub async fn serial_disconnect( pub async fn serial_disconnect(
state: State<'_, SerialConnectionState>, state: State<'_, SerialConnectionState>,
@@ -288,8 +293,8 @@ pub fn serial_export_csv_to_path(
state: State<'_, SerialConnectionState>, state: State<'_, SerialConnectionState>,
) -> Result<SerialExportResponse, SerialError> { ) -> Result<SerialExportResponse, SerialError> {
let output_path = resolve_export_path(file_path)?; let output_path = resolve_export_path(file_path)?;
let rec = resolve_record_for_export(&state)?; let record = resolve_record_for_export(&state)?;
let frame_count = write_record_to_csv(rec, &output_path)?; let frame_count = write_record_to_csv(record, &output_path)?;
let path = output_path.display().to_string(); let path = output_path.display().to_string();
info!("csv exported to {path}, frame_count={frame_count}"); info!("csv exported to {path}, frame_count={frame_count}");
@@ -306,20 +311,22 @@ pub fn serial_import_csv(
file_name: String, file_name: String,
csv_content: String, csv_content: String,
) -> Result<SerialImportResponse, SerialError> { ) -> Result<SerialImportResponse, SerialError> {
let packets = record::import_csv(Cursor::new(csv_content.into_bytes())) let mut importer = TactileACsvImporter::new(file_name.as_str());
let packets = importer
.load(Cursor::new(csv_content.into_bytes()))
.map_err(|_| SerialError::ImportError)?; .map_err(|_| SerialError::ImportError)?;
if packets.is_empty() { if packets.is_empty() {
return Err(SerialError::NoRecordedData); return Err(SerialError::NoRecordedData);
} }
let channel_count = 1; // fz is a single value per sample let channel_count = packets.first().map(|item| item.data.len()).unwrap_or(0);
let frame_count = packets.len(); let frame_count = packets.len();
let frames = packets let frames = packets
.into_iter() .into_iter()
.map(|packet| SerialImportFrame { .map(|packet| SerialImportFrame {
data: vec![packet.fz as i32], data: packet.data,
dts_ms: packet.timestamp_us / 1000, dts_ms: packet.dts_ms,
}) })
.collect(); .collect();
@@ -348,7 +355,7 @@ pub fn serial_import_csv_from_path(file_path: String) -> Result<SerialImportResp
fn resolve_record_for_export( fn resolve_record_for_export(
state: &State<'_, SerialConnectionState>, state: &State<'_, SerialConnectionState>,
) -> Result<SharedRecording, SerialError> { ) -> Result<SharedTactileRecording, SerialError> {
let current_record = { let current_record = {
let session = state.session.lock().map_err(|_| SerialError::StateError)?; let session = state.session.lock().map_err(|_| SerialError::StateError)?;
session session
@@ -399,7 +406,7 @@ fn snapshot_record_frame_count(
} }
fn write_record_to_csv( fn write_record_to_csv(
record: SharedRecording, record: SharedTactileRecording,
output_path: &Path, output_path: &Path,
) -> Result<usize, SerialError> { ) -> Result<usize, SerialError> {
if let Some(parent) = output_path.parent() { if let Some(parent) = output_path.parent() {
@@ -408,14 +415,14 @@ fn write_record_to_csv(
} }
} }
let file = std::fs::File::create(output_path).map_err(|_| SerialError::ExportError)?; let mut file = File::create(output_path).map_err(|_| SerialError::ExportError)?;
let frame_count = { let frame_count = {
let recording = record.lock().map_err(|_| SerialError::StateError)?; let recording = record.lock().map_err(|_| SerialError::StateError)?;
if recording.frames.is_empty() { if recording.frames.is_empty() {
return Err(SerialError::NoRecordedData); return Err(SerialError::NoRecordedData);
} }
record::export_recording_csv(&recording, file).map_err(|_| SerialError::ExportError)?; export_recording_csv(&recording, &mut file).map_err(|_| SerialError::ExportError)?;
recording.frames.len() recording.frames.len()
}; };

View File

@@ -0,0 +1,6 @@
use crate::serial_core::error::CodecError;
use std::time::Instant;
pub trait Codec<F> {
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<F>, CodecError>;
fn encode(&self, frame: &F) -> Result<Vec<u8>, CodecError>;
}

View File

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

View File

@@ -0,0 +1,382 @@
use crate::serial_core::error::CodecError;
use crate::serial_core::frame::{
FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame,
};
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
use crate::serial_core::{
codec::Codec,
frame::{TactileAFrame, TactileAFrameStatusCode},
};
use async_trait::async_trait;
use csv::StringRecord;
use anyhow::anyhow;
use std::io::Read;
use log::debug;
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
pub struct TactileACodec {
buffer: Vec<u8>,
expected_data_len: usize,
}
pub struct TactileACsvExporter {
channels: usize,
}
pub struct TactileACsvImporter {
channels: usize,
data_row: usize,
packets: Vec<TactileADataPacket>,
}
pub struct TactileAHandler;
#[derive(Clone)]
pub struct TactileADataPacket {
pub data: Vec<i32>,
pub dts_ms: u64,
}
impl From<u8> for TactileAFrameStatusCode {
fn from(value: u8) -> Self {
match value {
0 => TactileAFrameStatusCode::Success,
_ => TactileAFrameStatusCode::Failure,
}
}
}
impl TryFrom<&TactileARepFrame> for TactileADataPacket {
type Error = CodecError;
fn try_from(value: &TactileARepFrame) -> Result<TactileADataPacket, Self::Error> {
let data = TactileACodec::parse_data_frame(&value.payload)?;
let dts_ms = value.dts_ms;
Ok(TactileADataPacket {
data: data,
dts_ms: dts_ms,
})
}
}
impl TactileACodec {
pub fn new(cols: usize, rows: usize) -> TactileACodec {
Self {
buffer: Vec::new(),
expected_data_len: cols * rows * 2,
}
}
pub fn parse_data_frame(data: &[u8]) -> Result<Vec<i32>, CodecError> {
if data.len() % 2 != 0 {
return Err(CodecError::InvalidLength);
}
let vals: Vec<i32> = data
.chunks_exact(2)
.map(|chunk| {
let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32;
if raw < 15 {
0
} else {
raw
}
})
.collect::<Vec<i32>>();
Ok(vals)
}
pub fn build_req_frame(cols: usize, rows: usize) -> anyhow::Result<TactileAFrame> {
let header = [0x55, 0xAA];
let payload_len: usize = 9;
let device_addr: u8 = 0x34;
let extend_code: u8 = 0x00;
let func_code: u8 = 0xFB;
let start_addr: u32 = 7168;
let except_data_len: usize = cols * rows * 2;
let checksum: u8 = 0;
Ok(TactileAFrame::Req(TactileAReqFrame {
meta: TactileAFrameMetaData {
header,
payload_len,
device_addr,
extend_code,
func_code,
start_addr,
except_data_len,
checksum,
},
}))
}
}
impl Codec<TactileAFrame> for TactileACodec {
fn decode(
&mut self,
input: &[u8],
session_started_at: std::time::Instant,
) -> Result<Vec<TactileAFrame>, CodecError> {
self.buffer.extend_from_slice(input);
let mut frames: Vec<TactileAFrame> = Vec::new();
loop {
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH {
break;
}
let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]);
let Some(pos) = header_pos else {
self.buffer.clear();
break;
};
if pos > 0 {
self.buffer.drain(0..pos);
}
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH {
break;
}
let header = [self.buffer[0], self.buffer[1]];
let payload_len = u16::from_le_bytes([self.buffer[2], self.buffer[3]]) as usize;
let device_addr = self.buffer[4];
let extend_code = self.buffer[5];
let func_code = self.buffer[6];
let start_addr = u32::from_le_bytes([
self.buffer[7],
self.buffer[8],
self.buffer[9],
self.buffer[10],
]);
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
let status = TactileAFrameStatusCode::from(self.buffer[13]);
if except_data_len != self.expected_data_len {
debug!(
"unexpected payload length: expected {}, got {}, buffer_len={}",
self.expected_data_len,
except_data_len,
self.buffer.len()
);
self.buffer.drain(0..1);
continue;
}
let frame_length = except_data_len + FRAME_BUFFER_MIN_LENGTH;
if self.buffer.len() < frame_length {
break;
}
let need_check_data = self.buffer[0..14 + except_data_len].to_vec();
let payload = self.buffer[14..14 + except_data_len].to_vec();
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
let checksum = crc8_itu_alg.checksum(&need_check_data.as_slice());
if self.buffer[frame_length - 1] != checksum {
debug!(
"checksum mismatch: expected {:02X}, got {:02X}, frame_len={}",
checksum,
self.buffer[frame_length - 1],
frame_length
);
self.buffer.drain(0..1);
continue;
}
let dts_ms = elapsed_millis(session_started_at);
let meta: TactileAFrameMetaData = TactileAFrameMetaData {
header,
payload_len,
device_addr,
extend_code,
func_code,
start_addr,
except_data_len,
checksum,
};
frames.push(TactileAFrame::Rep({
TactileARepFrame {
meta,
status,
payload,
dts_ms,
}
}));
self.buffer.drain(0..frame_length);
}
Ok(frames)
}
fn encode(
&self,
frame: &TactileAFrame,
) -> Result<Vec<u8>, crate::serial_core::error::CodecError> {
match frame {
TactileAFrame::Req(f) => {
let mut req_bytes: Vec<u8> = Vec::new();
req_bytes.extend_from_slice(f.meta.header.as_slice());
req_bytes.extend_from_slice((f.meta.payload_len as u16).to_le_bytes().as_slice());
req_bytes.push(f.meta.device_addr);
req_bytes.push(f.meta.extend_code);
req_bytes.push(f.meta.func_code);
req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice());
req_bytes.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice());
let checksum = calc_crc8_itu(req_bytes.as_slice());
req_bytes.push(checksum);
Ok(req_bytes)
}
_ => {
Err(CodecError::InvalidFrameType)
}
}
}
}
#[async_trait]
impl FrameHandler<TactileAFrame, i32> for TactileAHandler {
async fn on_frame(&mut self, frame: &TactileAFrame) -> anyhow::Result<Option<Vec<i32>>> {
match frame {
TactileAFrame::Rep(rep) => {
let vals = TactileACodec::parse_data_frame(&rep.payload)?;
Ok(Some(vals))
}
_ => Ok(None),
}
}
}
impl TactileACsvExporter {
fn new(channels: usize) -> Self {
TactileACsvExporter { channels }
}
}
impl CsvExporter<TactileARepFrame> for TactileACsvExporter {
type Error = CodecError;
fn csv_header(&self, _recording: &Recording<TactileARepFrame>) -> Vec<String> {
let mut header: Vec<String> = Vec::new();
for i in 0..self.channels {
header.push(format!("channel{}", i + 1));
}
header.push("dts".to_string());
header.push("summary".to_string());
header
}
fn csv_row(
&self,
item: &RecordedFrame<TactileARepFrame>,
) -> anyhow::Result<Vec<String>> {
let packet = TactileADataPacket::try_from(&item.frame)?;
let summary: i32 = packet.data.iter().sum();
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
row.push(packet.dts_ms.to_string());
row.push(summary.to_string());
Ok(row)
}
}
impl CsvExporter<TactileAFrame> for TactileACsvExporter {
type Error = CodecError;
fn csv_header(&self, _recording: &Recording<TactileAFrame>) -> Vec<String> {
let mut header: Vec<String> = Vec::new();
for i in 0..self.channels {
header.push(format!("channel{}", i + 1));
}
header.push("dts".to_string());
header
}
fn csv_row(
&self,
item: &RecordedFrame<TactileAFrame>,
) -> anyhow::Result<Vec<String>> {
let rep = match &item.frame {
TactileAFrame::Rep(rep) => rep,
TactileAFrame::Req(_) => return Err(anyhow!("request frame cannot be exported to csv row")),
};
let packet = TactileADataPacket::try_from(rep)?;
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
row.push(packet.dts_ms.to_string());
Ok(row)
}
}
impl TactileACsvImporter {
pub fn new(_path: &str) -> TactileACsvImporter {
Self {
channels: 0,
data_row: 0,
packets: Vec::new(),
}
}
fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TactileADataPacket> {
if self.channels == 0 {
return Err(anyhow!("csv header is missing channel columns"));
}
if record.len() < self.channels + 1 {
return Err(anyhow!("csv row has insufficient columns"));
}
let mut data = Vec::with_capacity(self.channels);
for index in 0..self.channels {
let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?;
data.push(cell.parse::<i32>()?);
}
let dts_cell = record
.get(self.channels)
.ok_or_else(|| anyhow!("missing dts cell"))?;
let dts_ms = dts_cell.parse::<u64>()?;
Ok(TactileADataPacket {
data: data,
dts_ms: dts_ms,
})
}
}
impl CsvImporter<TactileADataPacket> for TactileACsvImporter {
fn load<R: Read>(&mut self, reader: R) -> anyhow::Result<Vec<TactileADataPacket>> {
let mut rdr = csv::Reader::from_reader(reader);
let headers = rdr.headers()?.clone();
self.channels = headers.len().saturating_sub(1);
self.data_row = 0;
self.packets.clear();
for record in rdr.records() {
let record = record?;
let packet = self.parse_record(record)?;
self.packets.push(packet);
self.data_row += 1;
}
Ok(self.packets.clone())
}
}
pub fn export_recording_csv<W>(recording: &Recording<TactileAFrame>, writer: W) -> anyhow::Result<()>
where
W: std::io::Write,
{
let channel_nb = recording
.frames
.iter()
.find_map(|frame| match &frame.frame {
TactileAFrame::Rep(rep) => Some(rep.payload.len() / 2),
TactileAFrame::Req(_) => None,
})
.unwrap_or(0);
let exporter = TactileACsvExporter::new(channel_nb);
write_csv(recording, &exporter, writer)
}

View File

@@ -0,0 +1,256 @@
use std::io::Read;
use std::time::Instant;
use crate::serial_core::frame::{FrameHandler};
use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame};
use anyhow::anyhow;
use async_trait::async_trait;
use csv::StringRecord;
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
use crate::serial_core::utils::{
elapsed_millis,
usize_to_u16_be_bytes
};
pub struct TestCodec {
buffer: Vec<u8>,
}
pub struct TestHandler;
impl TestCodec {
pub fn new() -> TestCodec {
Self { buffer: Vec::new() }
}
}
impl Codec<TestFrame> for TestCodec {
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<TestFrame>, CodecError> {
self.buffer.extend_from_slice(input);
let mut frames = Vec::new();
loop {
if self.buffer.len() < 6 {
break;
}
let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]);
let Some(pos) = header_pos else {
self.buffer.clear();
break;
};
if pos > 0 {
self.buffer.drain(0..pos);
}
if self.buffer.len() < 6 {
break;
}
let cmd = self.buffer[2];
let length_bytes = [self.buffer[3], self.buffer[4]];
let length = u16::from_be_bytes(length_bytes) as usize;
let frame_length = (length + 6) as usize;
if self.buffer.len() < frame_length {
break;
}
let payload = self.buffer[5..5 + length].to_vec();
// let checksum = crc8(payload.as_slice());
let crc8_alg = crc::Crc::<u8>::new(&crc::CRC_8_SMBUS);
let checksum = crc8_alg.checksum(payload.as_slice());
if self.buffer[frame_length - 1] != checksum {
self.buffer.drain(0..1);
continue;
}
let dts = elapsed_millis(session_started_at);
println!("dts_ms: {dts}");
frames.push(TestFrame {
header: [0xAA, 0x55],
cmd: cmd,
length: length,
payload: payload,
checksum: checksum,
dts_ms: dts,
});
self.buffer.drain(0..frame_length);
}
Ok(frames)
}
fn encode(&self, frame: &TestFrame) -> Result<Vec<u8>, CodecError> {
let _ = u16::try_from(frame.payload.len()).map_err(|_| CodecError::PayloadTooLarge)?;
let mut out = Vec::with_capacity(6 + frame.length);
out.extend_from_slice(&frame.header);
out.push(frame.cmd);
out.extend_from_slice(&usize_to_u16_be_bytes(frame.length));
out.extend_from_slice(&frame.payload);
out.push(frame.checksum);
Ok(out)
}
}
#[async_trait]
impl FrameHandler<TestFrame, i32> for TestHandler {
async fn on_frame(&mut self, frame: &TestFrame) -> anyhow::Result<Option<Vec<i32>>> {
match frame.cmd {
0x01 => {
let vals = parse_data_frame(&frame.payload)?;
Ok(Some(vals))
}
_ => Ok(None),
}
}
}
fn parse_data_frame(data: &[u8]) -> Result<Vec<i32>, CodecError> {
if data.len() % 2 != 0 {
return Err(CodecError::InvalidLength);
}
let vals: Vec<i32> = data
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]) as i32)
.collect::<Vec<i32>>();
Ok(vals)
}
pub struct TestCsvExporter;
pub struct TestCsvImporter {
channels: usize,
data_row: usize,
packets: Vec<TestDataPacket>,
}
#[derive(Clone)]
pub struct TestDataPacket {
pub data: Vec<i32>,
pub dts_ms: u64
}
impl TryFrom<&TestFrame> for TestDataPacket {
type Error = CodecError;
fn try_from(frame: &TestFrame) -> Result<TestDataPacket, Self::Error> {
let data = parse_data_frame(&frame.payload)?;
let dts = frame.dts_ms;
Ok(TestDataPacket { data: data, dts_ms: dts })
}
}
// impl From<TestFrame> for TestDataPacket {
// fn from(frame: TestFrame) -> Self {
// let data = parse_data_frame(&frame.payload)?;
// let dts = frame.dts_ms;
// TestDataPacket { data: data, dts_ms: dts }
// }
// }
impl CsvExporter<TestFrame> for TestCsvExporter {
type Error = CodecError;
fn csv_header(&self, recording: &Recording<TestFrame>) -> Vec<String> {
let channel_nb = recording
.frames
.iter()
.find_map(|frame| parse_data_frame(&frame.frame.payload).ok().map(|vals| vals.len()))
.unwrap_or(0);
let mut header: Vec<String> = Vec::new();
for i in 0..channel_nb {
header.push(format!("channel{}", i + 1));
}
header.push("dts".to_string());
header
}
fn csv_row(&self, item: &RecordedFrame<TestFrame>) -> anyhow::Result<Vec<String>> {
let packet: TestDataPacket = TestDataPacket::try_from(&item.frame)?;
let mut row: Vec<String> = packet.data.iter().map(|&x| x.to_string()).collect();
row.push(packet.dts_ms.to_string());
Ok(row)
}
}
impl TestCsvImporter {
pub fn new(_path: &str) -> TestCsvImporter {
Self {
channels: 0,
data_row: 0,
packets: Vec::new(),
}
}
fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TestDataPacket>{
if self.channels == 0 {
return Err(anyhow!("csv header is missing channel columns"));
}
if record.len() < self.channels + 1 {
return Err(anyhow!("csv row has insufficient columns"));
}
let mut data = Vec::with_capacity(self.channels);
for index in 0..self.channels {
let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?;
data.push(cell.parse::<i32>()?);
}
let dts_cell = record
.get(self.channels)
.ok_or_else(|| anyhow!("missing dts cell"))?;
let dts_ms = dts_cell.parse::<u64>()?;
Ok(TestDataPacket {
data: data,
dts_ms: dts_ms,
})
}
}
impl CsvImporter<TestDataPacket> for TestCsvImporter {
fn load<R: Read>(&mut self, reader: R) -> anyhow::Result<Vec<TestDataPacket>> {
let mut rdr = csv::Reader::from_reader(reader);
let headers = rdr.headers()?.clone();
self.channels = headers.len().saturating_sub(1);
self.data_row = 0;
self.packets.clear();
for record in rdr.records() {
let record = record?;
let packet = self.parse_record(record)?;
self.packets.push(packet);
self.data_row += 1;
}
Ok(self.packets.clone())
}
}
pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> anyhow::Result<()>
where
W: std::io::Write,
{
write_csv(recording, &TestCsvExporter, writer)
}
#[cfg(test)]
mod tests {
use super::*;
use csv::Reader;
use std::io::Cursor;
#[test]
fn test_read_csv_basic() -> anyhow::Result<()> {
let mut rdr = Reader::from_path("recording_20260329_125238.csv")?;
let headers = rdr.headers()?;
println!("headers: {:?}", headers);
for result in rdr.records() {
let record = result?;
println!("record: {:?}", record);
}
Ok(())
}
}

View File

@@ -0,0 +1,57 @@
use anyhow::Result;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestFrame {
pub header: [u8; 2],
pub cmd: u8,
pub length: usize,
pub payload: Vec<u8>,
pub checksum: u8,
pub dts_ms: u64
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TactileAFrameMetaData {
pub header: [u8; 2],
pub payload_len: usize,
pub device_addr: u8,
pub extend_code: u8,
pub func_code: u8,
pub start_addr: u32,
pub except_data_len: usize,
// pub status: u8,
// pub payload_data: Vec<u8>,
pub checksum: u8,
// pub dts_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TactileAReqFrame {
pub meta: TactileAFrameMetaData,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TactileARepFrame {
pub meta: TactileAFrameMetaData,
pub status: TactileAFrameStatusCode,
pub payload: Vec<u8>,
pub dts_ms: u64
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TactileAFrameStatusCode {
Success,
Failure
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TactileAFrame {
Req(TactileAReqFrame),
Rep(TactileARepFrame)
}
#[async_trait]
pub trait FrameHandler<F, T>: Send {
async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>;
}

View File

@@ -1,4 +1,34 @@
use crate::serial_core::{
frame::{TactileAFrame, TestFrame},
record::Recording,
};
pub mod codec;
pub mod codecs;
pub mod error; pub mod error;
pub mod frame;
pub mod model; pub mod model;
pub mod serial; pub mod serial;
pub mod record; pub mod record;
pub mod utils;
#[cfg(feature = "multi-dim")]
pub mod multi_dim_force;
pub type TestRecording = Recording<TestFrame>;
pub type TactileARecording = Recording<TactileAFrame>;
pub struct SerialConnection {
pub port: String,
}
pub fn connect(port: &str) -> Result<SerialConnection, String> {
let port = port.trim();
if port.is_empty() {
return Err("Serial port is required".to_string());
}
Ok(SerialConnection {
port: port.to_string(),
})
}

View File

@@ -1,6 +1,8 @@
use crate::serial_core::frame::TestFrame;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const MAX_POINTS: usize = 28;
const MAX_SUMMARY_POINTS: usize = 42; const MAX_SUMMARY_POINTS: usize = 42;
const PANEL_STALE_AFTER: Duration = Duration::from_millis(2400); const PANEL_STALE_AFTER: Duration = Duration::from_millis(2400);
@@ -11,6 +13,7 @@ pub struct HudPacket {
pub panels: Vec<HudSignalPanel>, pub panels: Vec<HudSignalPanel>,
pub summary: HudSummary, pub summary: HudSummary,
pub pressure_matrix: Option<Vec<f32>>, pub pressure_matrix: Option<Vec<f32>>,
pub spatial_force: Option<HudSpatialForce>,
} }
#[derive(serde::Serialize, Clone)] #[derive(serde::Serialize, Clone)]
@@ -72,12 +75,17 @@ pub struct HudSignalIcon {
pub tone: HudTone, pub tone: HudTone,
} }
pub struct HudChartState { #[derive(serde::Serialize, Clone)]
panels: HashMap<String, PanelEntry>, #[serde(rename_all = "camelCase")]
order: Vec<String>, pub struct HudSpatialForce {
summary_points: Vec<f32>, pub angle_deg: f32,
pressure_matrix: Option<Vec<f32>>, pub magnitude: f32,
last_frame_seen: Option<Instant>, pub confidence: f32,
}
struct HudPanelUpdate {
source_id: String,
values: Vec<f32>,
} }
struct PanelEntry { struct PanelEntry {
@@ -85,6 +93,15 @@ struct PanelEntry {
last_seen: Instant, last_seen: Instant,
} }
pub struct HudChartState {
panels: HashMap<String, PanelEntry>,
order: Vec<String>,
summary_points: Vec<f32>,
pressure_matrix: Option<Vec<f32>>,
spatial_force: Option<HudSpatialForce>,
last_frame_seen: Option<Instant>,
}
impl HudChartState { impl HudChartState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -92,27 +109,93 @@ impl HudChartState {
order: Vec::new(), order: Vec::new(),
summary_points: Vec::new(), summary_points: Vec::new(),
pressure_matrix: None, pressure_matrix: None,
spatial_force: None,
last_frame_seen: None, last_frame_seen: None,
} }
} }
pub fn record_summary(&mut self, value: f32) { pub fn record_summary(&mut self, value: f32) {
push_summary_point(&mut self.summary_points, value); push_summary_point(&mut self.summary_points, value);
self.last_frame_seen = Some(Instant::now());
} }
pub fn record_pressure_matrix(&mut self, values: &[f32]) { pub fn record_pressure_matrix(&mut self, values: &[i32]) {
if values.is_empty() { if values.is_empty() {
return; return;
} }
self.pressure_matrix = Some(values.to_vec());
self.pressure_matrix = Some(values.iter().map(|value| *value as f32).collect());
}
pub fn record_spatial_force(&mut self, spatial_force: Option<HudSpatialForce>) {
self.spatial_force = spatial_force;
}
pub fn apply_frame(&mut self, frame: &TestFrame, decoded_values: Option<&[i32]>) -> HudPacket {
let now = Instant::now();
self.last_frame_seen = Some(now);
for update in expand_frame_updates(frame, decoded_values) {
self.apply_update(update, now);
}
self.prune_stale_at(now);
self.snapshot()
} }
pub fn prune_stale(&mut self) -> Option<HudPacket> { pub fn prune_stale(&mut self) -> Option<HudPacket> {
let now = Instant::now();
let before = self.panels.len(); let before = self.panels.len();
let summary_before = self.summary_points.len(); let summary_points_before = self.summary_points.len();
let had_pressure_matrix = self.pressure_matrix.is_some();
let had_spatial_force = self.spatial_force.is_some();
self.prune_stale_at(Instant::now());
if before == self.panels.len()
&& summary_points_before == self.summary_points.len()
&& had_pressure_matrix == self.pressure_matrix.is_some()
&& had_spatial_force == self.spatial_force.is_some()
{
return None;
}
Some(self.snapshot())
}
fn apply_update(&mut self, update: HudPanelUpdate, now: Instant) {
if update.values.is_empty() {
return;
}
if !self.panels.contains_key(&update.source_id) {
let next_side = side_for_index(self.order.len());
self.order.push(update.source_id.clone());
self.panels.insert(
update.source_id.clone(),
PanelEntry {
panel: build_panel(&update.source_id, next_side, update.values.len()),
last_seen: now,
},
);
}
let entry = self
.panels
.get_mut(&update.source_id)
.expect("panel entry should exist after insertion");
entry.last_seen = now;
entry.panel.active = true;
ensure_panel_channels(&mut entry.panel, update.values.len());
for (index, value) in update.values.into_iter().enumerate() {
if let Some(series) = entry.panel.series.get_mut(index) {
push_point(&mut series.points, value);
}
}
refresh_panel_stats(&mut entry.panel);
}
fn prune_stale_at(&mut self, now: Instant) {
self.panels self.panels
.retain(|_, entry| now.duration_since(entry.last_seen) <= PANEL_STALE_AFTER); .retain(|_, entry| now.duration_since(entry.last_seen) <= PANEL_STALE_AFTER);
self.order.retain(|id| self.panels.contains_key(id)); self.order.retain(|id| self.panels.contains_key(id));
@@ -125,18 +208,9 @@ impl HudChartState {
if summary_stale { if summary_stale {
self.summary_points.clear(); self.summary_points.clear();
self.pressure_matrix = None; self.pressure_matrix = None;
self.spatial_force = None;
self.last_frame_seen = None; self.last_frame_seen = None;
} }
if before == self.panels.len() && summary_before == self.summary_points.len() {
return None;
}
Some(self.snapshot())
}
pub fn build_snapshot(&mut self) -> HudPacket {
self.snapshot()
} }
fn snapshot(&mut self) -> HudPacket { fn snapshot(&mut self) -> HudPacket {
@@ -153,6 +227,7 @@ impl HudChartState {
panels, panels,
summary: build_summary(&self.summary_points), summary: build_summary(&self.summary_points),
pressure_matrix: self.pressure_matrix.clone(), pressure_matrix: self.pressure_matrix.clone(),
spatial_force: self.spatial_force.clone(),
} }
} }
@@ -171,6 +246,106 @@ impl Default for HudChartState {
} }
} }
fn build_panel(source_id: &str, side: HudPanelSide, channel_count: usize) -> HudSignalPanel {
HudSignalPanel {
id: format!("panel-{source_id}"),
code: source_id.to_string(),
title: format!("Source {source_id}"),
side,
active: true,
series: build_panel_series(source_id, channel_count, &[]),
icons: build_panel_icons(source_id, channel_count),
latest: None,
min: None,
max: None,
}
}
fn expand_frame_updates(frame: &TestFrame, decoded_values: Option<&[i32]>) -> Vec<HudPanelUpdate> {
if let Some(values) = decoded_values {
if values.is_empty() {
return Vec::new();
}
return vec![HudPanelUpdate {
source_id: format_source_id(frame.cmd),
values: values.iter().map(|value| *value as f32).collect(),
}];
}
let chunks = frame.payload.chunks_exact(4);
if !frame.payload.is_empty() && chunks.remainder().is_empty() {
return chunks.map(build_update_from_chunk).collect();
}
vec![HudPanelUpdate {
source_id: format_source_id(frame.cmd),
values: fallback_values(frame),
}]
}
fn build_update_from_chunk(chunk: &[u8]) -> HudPanelUpdate {
HudPanelUpdate {
source_id: format_source_id(chunk[0]),
values: chunk[1..]
.iter()
.enumerate()
.map(|(index, byte)| normalize_value(*byte, tone_for_index(index)))
.collect(),
}
}
fn fallback_values(frame: &TestFrame) -> Vec<f32> {
let mut bytes = frame.payload.clone();
if bytes.is_empty() {
bytes.extend([
frame.cmd,
frame.length as u8,
frame.checksum,
frame.cmd.wrapping_add(frame.checksum),
]);
}
while bytes.len() < 3 {
let previous = *bytes.last().unwrap_or(&frame.cmd);
bytes.push(
previous
.wrapping_add(frame.cmd)
.wrapping_add(bytes.len() as u8),
);
}
bytes
.into_iter()
.enumerate()
.map(|(index, byte)| normalize_value(byte, tone_for_index(index)))
.collect()
}
fn normalize_value(byte: u8, tone: HudTone) -> f32 {
let base = (byte as f32 / 255.0) * 100.0;
let offset = match tone {
HudTone::Cyan => 6.0,
HudTone::Lime => 0.0,
HudTone::Orange => -6.0,
HudTone::Violet => 10.0,
HudTone::Gold => -10.0,
HudTone::Rose => 3.0,
};
(base + offset).clamp(0.0, 100.0)
}
fn format_source_id(byte: u8) -> String {
if byte.is_ascii_alphanumeric() {
(byte as char).to_ascii_uppercase().to_string()
} else {
format!("CH{:02X}", byte)
}
}
fn side_for_index(index: usize) -> HudPanelSide { fn side_for_index(index: usize) -> HudPanelSide {
if index % 2 == 0 { if index % 2 == 0 {
HudPanelSide::Left HudPanelSide::Left
@@ -179,6 +354,91 @@ fn side_for_index(index: usize) -> HudPanelSide {
} }
} }
fn push_point(points: &mut Vec<f32>, value: f32) {
if points.len() >= MAX_POINTS {
points.remove(0);
}
points.push((value * 10.0).round() / 10.0);
}
fn build_panel_series(
source_id: &str,
channel_count: usize,
previous: &[HudSignalSeries],
) -> Vec<HudSignalSeries> {
(0..channel_count)
.map(|index| HudSignalSeries {
id: format!("{source_id}-series-{}", index + 1),
tone: tone_for_index(index),
points: previous
.get(index)
.map(|series| series.points.clone())
.unwrap_or_default(),
})
.collect()
}
fn build_panel_icons(source_id: &str, channel_count: usize) -> Vec<HudSignalIcon> {
(0..channel_count)
.map(|index| HudSignalIcon {
id: format!("{source_id}-icon-{}", index + 1),
label: if channel_count == 1 {
"TOTAL".to_string()
} else {
format!("{source_id}-{}", index + 1)
},
tone: tone_for_index(index),
})
.collect()
}
fn ensure_panel_channels(panel: &mut HudSignalPanel, channel_count: usize) {
if panel.series.len() == channel_count && panel.icons.len() == channel_count {
return;
}
panel.series = build_panel_series(&panel.code, channel_count, &panel.series);
panel.icons = build_panel_icons(&panel.code, channel_count);
}
fn refresh_panel_stats(panel: &mut HudSignalPanel) {
let latest_values: Vec<f32> = panel
.series
.iter()
.filter_map(|series| series.points.last().copied())
.collect();
panel.latest = if latest_values.is_empty() {
None
} else {
Some(latest_values.iter().sum::<f32>() / latest_values.len() as f32)
};
panel.min = panel
.series
.iter()
.flat_map(|series| series.points.iter().copied())
.reduce(f32::min);
panel.max = panel
.series
.iter()
.flat_map(|series| series.points.iter().copied())
.reduce(f32::max);
}
fn tone_for_index(index: usize) -> HudTone {
match index % 6 {
0 => HudTone::Cyan,
1 => HudTone::Lime,
2 => HudTone::Orange,
3 => HudTone::Violet,
4 => HudTone::Gold,
_ => HudTone::Rose,
}
}
fn push_summary_point(points: &mut Vec<f32>, value: f32) { fn push_summary_point(points: &mut Vec<f32>, value: f32) {
if points.len() >= MAX_SUMMARY_POINTS { if points.len() >= MAX_SUMMARY_POINTS {
points.remove(0); points.remove(0);
@@ -203,3 +463,61 @@ fn now_millis() -> u64 {
.map(|duration| duration.as_millis() as u64) .map(|duration| duration.as_millis() as u64)
.unwrap_or_default() .unwrap_or_default()
} }
// #[cfg(test)]
// mod tests {
// use super::*;
//
// fn sample_frame() -> TestFrame {
// TestFrame {
// header: [0xAA, 0x55],
// cmd: 0x01,
// length: 4,
// payload: vec![0x00, 0x0A, 0x00, 0x14],
// checksum: 0,
//
// }
// }
//
// #[test]
// fn prune_stale_clears_panels_and_summary_after_timeout() {
// let mut state = HudChartState::new();
// let frame = sample_frame();
//
// state.record_summary(30.0);
// let _ = state.apply_frame(&frame, Some(&[10, 20]));
//
// let stale_now = Instant::now();
// let stale_seen = stale_now - PANEL_STALE_AFTER - Duration::from_millis(1);
//
// state.last_frame_seen = Some(stale_seen);
//
// for entry in state.panels.values_mut() {
// entry.last_seen = stale_seen;
// }
//
// let packet = state
// .prune_stale()
// .expect("stale data should emit an update");
//
// assert!(packet.panels.is_empty());
// assert!(packet.summary.points.is_empty());
// assert!(state.panels.is_empty());
// assert!(state.summary_points.is_empty());
// }
//
// #[test]
// fn prune_stale_keeps_recent_summary_points() {
// let mut state = HudChartState::new();
// let frame = sample_frame();
//
// state.record_summary(30.0);
// let _ = state.apply_frame(&frame, Some(&[10, 20]));
//
// state.last_frame_seen = Some(Instant::now());
//
// assert!(state.prune_stale().is_none());
// assert_eq!(state.summary_points, vec![30.0]);
// assert_eq!(state.panels.len(), 1);
// }
// }

View File

@@ -0,0 +1,527 @@
const SENSOR_ROWS: usize = 12;
const SENSOR_COLS: usize = 7;
const SENSOR_COUNT: usize = SENSOR_ROWS * SENSOR_COLS;
const CONTACT_ENTER_TOTAL_THRESHOLD: f32 = 520.0;
const CONTACT_ENTER_PEAK_THRESHOLD: f32 = 50.0;
const CONTACT_EXIT_TOTAL_THRESHOLD: f32 = 260.0;
const CONTACT_EXIT_PEAK_THRESHOLD: f32 = 28.0;
const CONTACT_ENTER_FRAMES_REQUIRED: usize = 2;
const CONTACT_EXIT_FRAMES_REQUIRED: usize = 8;
const BASELINE_IDLE_ALPHA: f32 = 0.035;
const BASELINE_BOOTSTRAP_ALPHA: f32 = 1.0;
const BASELINE_NOISE_FLOOR: f32 = 5.0;
const ACTIVE_CELL_MIN_VALUE: f32 = 18.0;
const ACTIVE_CELL_PEAK_RATIO: f32 = 0.14;
const MIN_ACTIVE_CELLS: usize = 3;
const ANCHOR_LERP_ALPHA: f32 = 0.018;
const VECTOR_SMOOTHING_ALPHA: f32 = 0.16;
const REPORT_MAGNITUDE_ENTER: f32 = 0.12;
const REPORT_MAGNITUDE_EXIT: f32 = 0.045;
const REPORT_CONFIDENCE_ENTER: f32 = 0.14;
const REPORT_CONFIDENCE_EXIT: f32 = 0.06;
const REPORT_HOLD_FRAMES: usize = 10;
const ASYMMETRY_WEIGHT: f32 = 1.1;
const DRIFT_WEIGHT: f32 = 0.65;
const MOTION_WEIGHT: f32 = 0.25;
#[derive(Debug, Clone, Copy)]
pub struct PztSpatialAnalysis {
pub angle_deg: f32,
pub magnitude: f32,
pub planar_x: f32,
pub planar_y: f32,
pub confidence: f32,
pub contact_active: bool,
pub reportable: bool,
}
pub struct PztProcessor {
baseline_frame: Option<Vec<f32>>,
contact_active: bool,
contact_enter_counter: usize,
contact_exit_counter: usize,
anchor_cop_x: Option<f32>,
anchor_cop_y: Option<f32>,
last_cop_x: Option<f32>,
last_cop_y: Option<f32>,
smoothed_x: f32,
smoothed_y: f32,
report_active: bool,
report_hold_counter: usize,
held_report: Option<PztSpatialAnalysis>,
}
#[derive(Clone, Copy)]
struct ContactStats {
total: f32,
peak: f32,
active_total: f32,
active_cells: usize,
min_row: usize,
max_row: usize,
min_col: usize,
max_col: usize,
cop_x: f32,
cop_y: f32,
asymmetry_x: f32,
asymmetry_y: f32,
}
impl PztProcessor {
pub fn new() -> Self {
Self {
baseline_frame: None,
contact_active: false,
contact_enter_counter: 0,
contact_exit_counter: 0,
anchor_cop_x: None,
anchor_cop_y: None,
last_cop_x: None,
last_cop_y: None,
smoothed_x: 0.0,
smoothed_y: 0.0,
report_active: false,
report_hold_counter: 0,
held_report: None,
}
}
fn reset_tracking_state(&mut self) {
self.contact_active = false;
self.contact_enter_counter = 0;
self.contact_exit_counter = 0;
self.anchor_cop_x = None;
self.anchor_cop_y = None;
self.last_cop_x = None;
self.last_cop_y = None;
self.smoothed_x = 0.0;
self.smoothed_y = 0.0;
}
fn reset_report_state(&mut self) {
self.report_active = false;
self.report_hold_counter = 0;
self.held_report = None;
}
fn update_idle_baseline(&mut self, raw_frame: &[f32], alpha: f32) {
match self.baseline_frame.as_mut() {
Some(baseline) => {
for (base, current) in baseline.iter_mut().zip(raw_frame.iter().copied()) {
*base += (current - *base) * alpha;
}
}
None => {
self.baseline_frame = Some(raw_frame.to_vec());
}
}
}
fn subtract_baseline(&mut self, raw_frame: &[f32]) -> Vec<f32> {
if self.baseline_frame.is_none() {
self.update_idle_baseline(raw_frame, BASELINE_BOOTSTRAP_ALPHA);
}
let baseline = self
.baseline_frame
.as_ref()
.expect("baseline should exist after bootstrap");
raw_frame
.iter()
.zip(baseline.iter())
.map(|(raw, base)| (raw - base - BASELINE_NOISE_FLOOR).max(0.0))
.collect()
}
fn pressure_metrics(frame: &[f32]) -> (f32, f32) {
let total = frame.iter().sum::<f32>();
let peak = frame.iter().copied().fold(0.0, f32::max);
(total, peak)
}
fn is_contact_enter_frame(frame: &[f32]) -> bool {
let (total, peak) = Self::pressure_metrics(frame);
total >= CONTACT_ENTER_TOTAL_THRESHOLD && peak >= CONTACT_ENTER_PEAK_THRESHOLD
}
fn is_contact_exit_frame(frame: &[f32]) -> bool {
let (total, peak) = Self::pressure_metrics(frame);
total <= CONTACT_EXIT_TOTAL_THRESHOLD || peak <= CONTACT_EXIT_PEAK_THRESHOLD
}
fn inactive_analysis() -> PztSpatialAnalysis {
PztSpatialAnalysis {
angle_deg: 0.0,
magnitude: 0.0,
planar_x: 0.0,
planar_y: 0.0,
confidence: 0.0,
contact_active: false,
reportable: false,
}
}
fn weak_contact_analysis() -> PztSpatialAnalysis {
PztSpatialAnalysis {
contact_active: true,
..Self::inactive_analysis()
}
}
fn compute_contact_stats(frame: &[f32]) -> Option<ContactStats> {
let total = frame.iter().sum::<f32>();
if total <= 0.0 {
return None;
}
let peak = frame.iter().copied().fold(0.0, f32::max);
if peak <= 0.0 {
return None;
}
let active_threshold = (peak * ACTIVE_CELL_PEAK_RATIO).max(ACTIVE_CELL_MIN_VALUE);
let mut active_total = 0.0;
let mut active_cells = 0usize;
let mut weighted_col_sum = 0.0;
let mut weighted_row_sum = 0.0;
let mut min_row = SENSOR_ROWS;
let mut max_row = 0usize;
let mut min_col = SENSOR_COLS;
let mut max_col = 0usize;
for row in 0..SENSOR_ROWS {
for col in 0..SENSOR_COLS {
let index = row * SENSOR_COLS + col;
let value = frame[index];
if value < active_threshold {
continue;
}
active_cells += 1;
active_total += value;
weighted_col_sum += value * col as f32;
weighted_row_sum += value * row as f32;
min_row = min_row.min(row);
max_row = max_row.max(row);
min_col = min_col.min(col);
max_col = max_col.max(col);
}
}
if active_cells < MIN_ACTIVE_CELLS || active_total <= 0.0 {
return None;
}
let cop_x = weighted_col_sum / active_total;
let cop_y = weighted_row_sum / active_total;
let bbox_center_x = (min_col + max_col) as f32 * 0.5;
let bbox_center_y = (min_row + max_row) as f32 * 0.5;
let half_width = ((max_col - min_col).max(1) as f32) * 0.5;
let half_height = ((max_row - min_row).max(1) as f32) * 0.5;
let mut asymmetry_x = 0.0;
let mut asymmetry_y = 0.0;
for row in min_row..=max_row {
for col in min_col..=max_col {
let index = row * SENSOR_COLS + col;
let value = frame[index];
if value < active_threshold {
continue;
}
asymmetry_x += value * ((col as f32 - bbox_center_x) / half_width);
asymmetry_y += value * ((row as f32 - bbox_center_y) / half_height);
}
}
Some(ContactStats {
total,
peak,
active_total,
active_cells,
min_row,
max_row,
min_col,
max_col,
cop_x,
cop_y,
asymmetry_x: asymmetry_x / active_total,
asymmetry_y: asymmetry_y / active_total,
})
}
fn compute_vector_angle(x: f32, y: f32) -> (f32, f32) {
let magnitude = (x * x + y * y).sqrt();
if magnitude <= f32::EPSILON {
return (0.0, 0.0);
}
let mut angle = y.atan2(x).to_degrees();
if angle < 0.0 {
angle += 360.0;
}
(angle, magnitude)
}
fn update_contact_state(&mut self, raw_frame: &[f32], frame: &[f32]) -> bool {
if self.contact_active {
if Self::is_contact_exit_frame(frame) {
self.contact_exit_counter += 1;
if self.contact_exit_counter >= CONTACT_EXIT_FRAMES_REQUIRED {
self.update_idle_baseline(raw_frame, BASELINE_IDLE_ALPHA);
self.reset_tracking_state();
self.reset_report_state();
return false;
}
} else {
self.contact_exit_counter = 0;
}
return true;
}
if Self::is_contact_enter_frame(frame) {
self.contact_enter_counter += 1;
if self.contact_enter_counter >= CONTACT_ENTER_FRAMES_REQUIRED {
self.contact_active = true;
self.contact_enter_counter = 0;
self.contact_exit_counter = 0;
return true;
}
return false;
}
self.contact_enter_counter = 0;
self.update_idle_baseline(raw_frame, BASELINE_IDLE_ALPHA);
false
}
fn store_report(&mut self, mut analysis: PztSpatialAnalysis) -> PztSpatialAnalysis {
analysis.reportable = true;
self.report_active = true;
self.report_hold_counter = 0;
self.held_report = Some(analysis);
analysis
}
fn hold_or_drop_report(&mut self) -> PztSpatialAnalysis {
if self.report_active && self.report_hold_counter < REPORT_HOLD_FRAMES {
self.report_hold_counter += 1;
if let Some(mut held) = self.held_report {
held.reportable = true;
return held;
}
}
self.reset_report_state();
Self::weak_contact_analysis()
}
fn stabilize_report(&mut self, analysis: PztSpatialAnalysis) -> PztSpatialAnalysis {
if !analysis.contact_active {
self.reset_report_state();
return analysis;
}
let can_enter = analysis.magnitude >= REPORT_MAGNITUDE_ENTER
&& analysis.confidence >= REPORT_CONFIDENCE_ENTER;
let can_stay = analysis.magnitude >= REPORT_MAGNITUDE_EXIT
&& analysis.confidence >= REPORT_CONFIDENCE_EXIT;
if self.report_active {
if can_stay {
return self.store_report(analysis);
}
return self.hold_or_drop_report();
}
if can_enter {
return self.store_report(analysis);
}
analysis
}
pub fn get_pzt_analysis(
&mut self,
adc_data: &[f32],
) -> Result<PztSpatialAnalysis, &'static str> {
if adc_data.len() != SENSOR_COUNT {
return Err("ADC data length must be 84");
}
let baseline_subtracted = self.subtract_baseline(adc_data);
if !self.update_contact_state(adc_data, &baseline_subtracted) {
return Ok(Self::inactive_analysis());
}
let Some(stats) = Self::compute_contact_stats(&baseline_subtracted) else {
return Ok(self.stabilize_report(Self::weak_contact_analysis()));
};
let Some(anchor_x) = self.anchor_cop_x else {
self.anchor_cop_x = Some(stats.cop_x);
self.anchor_cop_y = Some(stats.cop_y);
self.last_cop_x = Some(stats.cop_x);
self.last_cop_y = Some(stats.cop_y);
return Ok(self.stabilize_report(Self::weak_contact_analysis()));
};
let anchor_y = self.anchor_cop_y.unwrap_or(stats.cop_y);
let last_x = self.last_cop_x.unwrap_or(stats.cop_x);
let last_y = self.last_cop_y.unwrap_or(stats.cop_y);
let drift_x = stats.cop_x - anchor_x;
let drift_y = stats.cop_y - anchor_y;
let motion_x = stats.cop_x - last_x;
let motion_y = stats.cop_y - last_y;
let combined_x = stats.asymmetry_x * ASYMMETRY_WEIGHT
+ drift_x * DRIFT_WEIGHT
+ motion_x * MOTION_WEIGHT;
let combined_y = stats.asymmetry_y * ASYMMETRY_WEIGHT
+ drift_y * DRIFT_WEIGHT
+ motion_y * MOTION_WEIGHT;
self.smoothed_x += (combined_x - self.smoothed_x) * 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_y = Some(stats.cop_y);
let planar_x = self.smoothed_x;
let planar_y = -self.smoothed_y;
let (angle_deg, magnitude) = Self::compute_vector_angle(planar_x, planar_y);
let active_span_rows = (stats.max_row - stats.min_row + 1) as f32 / SENSOR_ROWS as f32;
let active_span_cols = (stats.max_col - stats.min_col + 1) as f32 / SENSOR_COLS as f32;
let activity = (stats.active_cells as f32 / SENSOR_COUNT as f32).clamp(0.0, 1.0);
let span = ((active_span_rows + active_span_cols) * 0.5).clamp(0.0, 1.0);
let pressure_ratio = (stats.active_total / stats.total.max(1.0)).clamp(0.0, 1.0);
let peak_ratio =
(stats.peak / (stats.total / stats.active_cells as f32 + 1.0)).clamp(0.0, 1.0);
let confidence =
((activity * 0.35) + (span * 0.2) + (pressure_ratio * 0.3) + (peak_ratio * 0.15))
.clamp(0.0, 1.0);
Ok(self.stabilize_report(PztSpatialAnalysis {
angle_deg,
magnitude,
planar_x,
planar_y,
confidence,
contact_active: true,
reportable: false,
}))
}
pub fn get_pzt_angle(&mut self, adc_data: &[f32]) -> Result<f32, &'static str> {
Ok(self.get_pzt_analysis(adc_data)?.angle_deg)
}
pub fn should_report(analysis: &PztSpatialAnalysis) -> bool {
analysis.reportable
}
pub fn reset_baseline(&mut self) {
self.baseline_frame = None;
self.reset_tracking_state();
self.reset_report_state();
}
}
#[cfg(test)]
mod tests {
use super::{PztProcessor, SENSOR_COLS, SENSOR_ROWS};
fn index(row: usize, col: usize) -> usize {
row * SENSOR_COLS + col
}
fn make_frame(active: &[(usize, usize, f32)]) -> [f32; SENSOR_ROWS * SENSOR_COLS] {
let mut frame = [0.0; SENSOR_ROWS * SENSOR_COLS];
for (row, col, value) in active {
frame[index(*row, *col)] = *value;
}
frame
}
#[test]
fn idle_frame_does_not_report_contact() {
let mut processor = PztProcessor::new();
let frame = [0.0; SENSOR_ROWS * SENSOR_COLS];
let analysis = processor.get_pzt_analysis(&frame).unwrap();
assert!(!analysis.contact_active);
assert!(!analysis.reportable);
assert_eq!(analysis.magnitude, 0.0);
}
#[test]
fn right_heavy_contact_reports_rightward_angle_after_confirmation() {
let mut processor = PztProcessor::new();
let baseline = [0.0; SENSOR_ROWS * SENSOR_COLS];
let contact = make_frame(&[
(5, 2, 120.0),
(5, 3, 180.0),
(5, 4, 280.0),
(6, 2, 110.0),
(6, 3, 170.0),
(6, 4, 260.0),
(7, 2, 100.0),
(7, 3, 150.0),
(7, 4, 240.0),
]);
let _ = processor.get_pzt_analysis(&baseline).unwrap();
let mut analysis = processor.get_pzt_analysis(&contact).unwrap();
for _ in 0..8 {
analysis = processor.get_pzt_analysis(&contact).unwrap();
}
assert!(analysis.contact_active);
assert!(analysis.reportable);
assert!(analysis.magnitude > 0.0);
assert!(analysis.angle_deg <= 45.0 || analysis.angle_deg >= 315.0);
}
#[test]
fn report_stays_active_through_short_weak_gap() {
let mut processor = PztProcessor::new();
let baseline = [0.0; SENSOR_ROWS * SENSOR_COLS];
let contact = make_frame(&[
(5, 2, 120.0),
(5, 3, 180.0),
(5, 4, 280.0),
(6, 2, 110.0),
(6, 3, 170.0),
(6, 4, 260.0),
(7, 2, 100.0),
(7, 3, 150.0),
(7, 4, 240.0),
]);
let weak = make_frame(&[(5, 3, 55.0), (5, 4, 60.0), (6, 3, 50.0), (6, 4, 58.0)]);
let _ = processor.get_pzt_analysis(&baseline).unwrap();
for _ in 0..10 {
let _ = processor.get_pzt_analysis(&contact).unwrap();
}
let analysis = processor.get_pzt_analysis(&weak).unwrap();
assert!(analysis.reportable);
}
}

View File

@@ -1,5 +1,3 @@
use eskin_finger_sdk::types::FingerSample;
#[derive(Clone)] #[derive(Clone)]
pub struct FrameTiming { pub struct FrameTiming {
pub pts_ms: Option<u64>, pub pts_ms: Option<u64>,
@@ -9,82 +7,50 @@ pub struct FrameTiming {
#[derive(Clone)] #[derive(Clone)]
pub struct RecordedFrame<F> { pub struct RecordedFrame<F> {
pub timing: FrameTiming, pub timing: FrameTiming,
pub frame: F, pub frame: F
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct Recording<F> { pub struct Recording<F> {
pub frames: Vec<RecordedFrame<F>>, pub frames: Vec<RecordedFrame<F>>
} }
impl<F> Recording<F> { impl<F> Recording<F> {
pub fn new() -> Recording<F> { pub fn new() -> Recording<F> { Self { frames: Vec::new() } }
Self { pub fn push(&mut self, ite: RecordedFrame<F>) {
frames: Vec::new(), self.frames.push(ite);
}
}
pub fn push(&mut self, item: RecordedFrame<F>) {
self.frames.push(item);
} }
} }
pub type FingerRecording = Recording<FingerSample>; pub trait CsvExporter<F> {
type Error: std::error::Error + Send + Sync + 'static;
fn csv_header(&self, recording: &Recording<F>) -> Vec<String>;
fn csv_row(&self, item: &RecordedFrame<F>) -> anyhow::Result<Vec<String>>;
}
pub fn export_recording_csv<W>( // TODO: CsvImporter
recording: &Recording<FingerSample>, pub trait CsvImporter<P> {
mut writer: W, fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
}
pub fn write_csv<F, E, W>(
recording: &Recording<F>,
exporter: &E,
writer: W,
) -> anyhow::Result<()> ) -> anyhow::Result<()>
where where
E: CsvExporter<F>,
W: std::io::Write, W: std::io::Write,
{ {
// Infer channel count from the first sample's combined_forces (just fz) let header = exporter.csv_header(&recording);
// We write: timestamp_us, sequence, module, fx, fy, fz let mut wrt = csv::Writer::from_writer(writer);
let mut wrt = csv::Writer::from_writer(&mut writer); wrt.write_record(header)?;
wrt.write_record(["timestamp_us", "sequence", "module", "fx", "fy", "fz"])?; for f in &recording.frames {
let row = exporter.csv_row(f)?;
for frame in &recording.frames { wrt.write_record(&row)?;
let s = &frame.frame;
wrt.write_record(&[
s.timestamp_us.to_string(),
s.sequence.to_string(),
format!("{:?}", s.combined_forces.module),
s.combined_forces.force.fx.to_string(),
s.combined_forces.force.fy.to_string(),
s.combined_forces.force.fz.to_string(),
])?;
} }
wrt.flush()?; wrt.flush()?;
Ok(()) Ok(())
} }
pub struct FingerSampleCsvPacket {
pub timestamp_us: u64,
pub sequence: u32,
pub fz: u32,
}
pub fn import_csv<R: std::io::Read>(
reader: R,
) -> anyhow::Result<Vec<FingerSampleCsvPacket>> {
let mut rdr = csv::Reader::from_reader(reader);
let mut packets = Vec::new();
for result in rdr.records() {
let record = result?;
if record.len() < 6 {
continue;
}
let timestamp_us = record.get(0).unwrap_or("0").parse::<u64>().unwrap_or(0);
let sequence = record.get(1).unwrap_or("0").parse::<u32>().unwrap_or(0);
let fz = record.get(5).unwrap_or("0").parse::<u32>().unwrap_or(0);
packets.push(FingerSampleCsvPacket {
timestamp_us,
sequence,
fz,
});
}
Ok(packets)
}

View File

@@ -1,160 +1,450 @@
use crate::serial_core::model::HudChartState; #[cfg(feature = "devkit")]
use crate::devkit::{proto::SensorFrame, DevKitState};
use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::tactile_a::TactileACodec;
use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame};
use crate::serial_core::model::{HudChartState, HudPacket, HudSpatialForce};
#[cfg(feature = "multi-dim")]
use crate::serial_core::multi_dim_force::PztProcessor;
use crate::serial_core::record::Recording; use crate::serial_core::record::Recording;
use eskin_finger_sdk::channel::DeviceEvent; use crate::serial_core::record::{FrameTiming, RecordedFrame};
use eskin_finger_sdk::config::DeviceConfig; use anyhow::Result;
use eskin_finger_sdk::device::{EskinDevice, EskinDeviceInner}; use log::debug;
use eskin_finger_sdk::transport::SerialPortTransport; use std::future::pending;
use eskin_finger_sdk::types::FingerSample; #[cfg(feature = "devkit")]
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[cfg(feature = "devkit")]
use tauri::Manager;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{self, Duration, MissedTickBehavior};
use tokio_serial::SerialStream;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use super::model::HudPacket; const AUTO_SUB_INTERVAL: Duration = Duration::from_nanos(16_666_667);
pub struct SdkSession { pub enum PollMode<F> {
pub device: EskinDeviceInner, Disable,
Enabled(Box<dyn PollRequester<F>>),
} }
pub fn open_device(port: &str) -> Result<SdkSession, String> { struct PendingSubFrame<F> {
let port = port.trim(); frame: F,
if port.is_empty() { values: Vec<i32>,
return Err("Serial port is required".to_string()); spatial_force: Option<HudSpatialForce>,
}
pub trait SerialFrame: Clone + Send + 'static {
fn dts_ms(&self) -> u64;
fn to_hud_packet(
&self,
chart_state: &mut HudChartState,
display_values: Option<&[i32]>,
) -> Option<HudPacket>;
}
impl SerialFrame for TestFrame {
fn dts_ms(&self) -> u64 {
self.dts_ms
} }
let transport = SerialPortTransport::new(port, 921600); fn to_hud_packet(
let config = DeviceConfig::default(); &self,
let mut device = EskinDeviceInner::new(config, Box::new(transport)); chart_state: &mut HudChartState,
device.open().map_err(|e| e.to_string())?; display_values: Option<&[i32]>,
) -> Option<HudPacket> {
Ok(SdkSession { device }) Some(chart_state.apply_frame(self, display_values))
}
} }
pub async fn run_stream( impl SerialFrame for TactileAFrame {
fn dts_ms(&self) -> u64 {
match self {
TactileAFrame::Req(_) => 0,
TactileAFrame::Rep(rep) => rep.dts_ms,
}
}
fn to_hud_packet(
&self,
chart_state: &mut HudChartState,
display_values: Option<&[i32]>,
) -> Option<HudPacket> {
match self {
TactileAFrame::Req(_) => None,
TactileAFrame::Rep(rep) => {
let proxy = TestFrame {
header: rep.meta.header,
cmd: rep.meta.func_code,
length: rep.meta.except_data_len,
payload: rep.payload.clone(),
checksum: rep.meta.checksum,
dts_ms: rep.dts_ms,
};
Some(chart_state.apply_frame(&proxy, display_values))
}
}
}
}
pub trait PollRequester<F>: Send {
fn poll_interval(&self) -> Option<Duration> {
None
}
fn should_request(&mut self) -> bool {
true
}
fn next_request(&mut self) -> Result<Option<F>> {
Ok(None)
}
fn on_rx_frame(&mut self, _frame: &F) {}
}
#[derive(Default)]
pub struct NoopPollRequester;
impl<F> PollRequester<F> for NoopPollRequester {}
pub struct TactileAPollRequester {
period: Duration,
cols: usize,
rows: usize,
awaiting_reply: bool,
last_request_at: Option<Instant>,
reply_timeout: Duration,
}
impl TactileAPollRequester {
pub fn new(period: Duration, cols: usize, rows: usize, reply_timeout: Duration) -> Self {
Self {
period,
cols,
rows,
awaiting_reply: false,
last_request_at: None,
reply_timeout,
}
}
}
impl PollRequester<TactileAFrame> for TactileAPollRequester {
fn poll_interval(&self) -> Option<Duration> {
Some(self.period)
}
fn should_request(&mut self) -> bool {
if !self.awaiting_reply {
return true;
}
let timed_out = self
.last_request_at
.map(|t| t.elapsed() >= self.reply_timeout)
.unwrap_or(false);
if timed_out {
self.awaiting_reply = false;
self.last_request_at = None;
return true;
}
false
}
fn next_request(&mut self) -> Result<Option<TactileAFrame>> {
let req = TactileACodec::build_req_frame(self.cols, self.rows)?;
self.awaiting_reply = true;
self.last_request_at = Some(Instant::now());
Ok(Some(req))
}
fn on_rx_frame(&mut self, frame: &TactileAFrame) {
if matches!(frame, TactileAFrame::Rep(_)) {
self.awaiting_reply = false;
self.last_request_at = None
}
}
}
pub async fn run_serial<C, H, T, F>(
app: AppHandle, app: AppHandle,
device: &mut EskinDeviceInner, port: SerialStream,
codec: C,
handler: H,
session_started_at: Instant,
recording: Arc<Mutex<Recording<F>>>,
cancel: CancellationToken, cancel: CancellationToken,
) -> Result<(), String> { ) -> Result<()>
device where
.start_stream() F: SerialFrame,
.map_err(|e| format!("start_stream failed: {e}"))?; C: Codec<F> + Send + 'static,
H: FrameHandler<F, T> + Send + 'static,
T: Into<i32>,
{
run_serial_with_poll(
app,
port,
codec,
handler,
session_started_at,
recording,
cancel,
PollMode::Disable,
)
.await
}
let channels = device.channels(); pub async fn run_serial_with_poll<C, H, T, F>(
let mut chart_state = HudChartState::new(); app: AppHandle,
mut port: SerialStream,
let result = loop { mut codec: C,
tokio::select! { mut handler: H,
_ = cancel.cancelled() => { session_started_at: Instant,
break Ok(()); recording: Arc<Mutex<Recording<F>>>,
} cancel: CancellationToken,
_ = tokio::time::sleep(tokio::time::Duration::from_millis(1)) => {} poll_mode: PollMode<F>,
} ) -> Result<()>
where
// Try to receive a sample (non-blocking-ish via small timeout) F: SerialFrame,
match channels.recv_sample(5) { C: Codec<F> + Send + 'static,
Ok(sample) => { H: FrameHandler<F, T> + Send + 'static,
if let Some(packet) = build_hud_packet_from_sample(&sample, &mut chart_state) { T: Into<i32>,
let _ = app.emit("hud_stream", packet); {
} let mut requester = match poll_mode {
} PollMode::Disable => None,
Err(eskin_finger_sdk::error::SdkError::Timeout) => { PollMode::Enabled(r) => Some(r),
// No sample yet, check for events
}
Err(e) => {
break Err(format!("sample recv error: {e}"));
}
}
// Drain any events
if let Err(e) = drain_events(&channels) {
break Err(e);
}
}; };
let _ = device.stop_stream(); let mut poll_interval = requester.as_ref().and_then(|r| r.poll_interval()).map(|d| {
result let mut it = time::interval(d);
} it.set_missed_tick_behavior(MissedTickBehavior::Skip);
it
});
let mut poll_sub_interval = time::interval(AUTO_SUB_INTERVAL);
poll_sub_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
pub async fn run_stream_with_record(
app: AppHandle,
device: &mut EskinDeviceInner,
cancel: CancellationToken,
recording: std::sync::Arc<std::sync::Mutex<Recording<FingerSample>>>,
) -> Result<(), String> {
device
.start_stream()
.map_err(|e| format!("start_stream failed: {e}"))?;
let channels = device.channels();
let mut chart_state = HudChartState::new(); let mut chart_state = HudChartState::new();
let mut buffer = [0u8; 1024];
let mut prune_interval = time::interval(Duration::from_millis(450));
#[cfg(feature = "multi-dim")]
let mut pzt_processor = PztProcessor::new();
let mut pending_sub_frame: Option<PendingSubFrame<F>> = None;
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
let result = loop { loop {
tokio::select! { tokio::select! {
_ = cancel.cancelled() => { _ = cancel.cancelled() => break,
break Ok(()); _ = async {
match poll_interval.as_mut() {
Some(it) => {
it.tick().await;
}
None => pending::<()>().await,
}
} => {
if let Some(r) = requester.as_mut() {
if r.should_request() {
if let Some(req) = r.next_request()? {
let bytes = codec.encode(&req)?;
port.write_all(&bytes).await?;
}
}
}
} }
_ = tokio::time::sleep(tokio::time::Duration::from_millis(1)) => {} _ = prune_interval.tick() => {
} if let Some(packet) = chart_state.prune_stale() {
app.emit("hud_stream", packet)?;
}
}
_ = poll_sub_interval.tick() => {
if let Some(pending) = pending_sub_frame.take() {
let display_values = build_display_values(
&mut chart_state,
pending.values.as_slice(),
pending.spatial_force,
);
if let Some(packet) = pending
.frame
.to_hud_packet(&mut chart_state, display_values.as_deref())
{
app.emit("hud_stream", packet)?;
}
}
}
read_result = port.read(&mut buffer) => {
let n = read_result?;
if n == 0 {
// Some serial drivers can resolve reads with 0 bytes repeatedly.
// Yield here so timer-driven poll requests are not starved by a busy loop.
tokio::task::yield_now().await;
continue;
}
let frames = codec.decode(&buffer[..n], session_started_at)?;
for frame in frames {
if let Some(r) = requester.as_mut() {
r.on_rx_frame(&frame);
}
let decode_res = handler
.on_frame(&frame)
.await?
.map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>());
match channels.recv_sample(5) {
Ok(sample) => {
// Record
{
let mut record = recording let mut record = recording
.lock() .lock()
.map_err(|_| "recording state poisoned".to_string())?; .map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
record.push(crate::serial_core::record::RecordedFrame { record.push(RecordedFrame {
timing: crate::serial_core::record::FrameTiming { timing: FrameTiming {
pts_ms: None, pts_ms: None,
dts_ms: sample.timestamp_us / 1000, dts_ms: frame.dts_ms(),
}, },
frame: sample.clone(), frame: frame.clone(),
}); });
} drop(record);
if let Some(packet) = build_hud_packet_from_sample(&sample, &mut chart_state) { if let Some(vals) = decode_res {
let _ = app.emit("hud_stream", packet); let mut spatial_force = None;
#[cfg(feature = "multi-dim")]
{
let pzt_values = vals.iter().map(|value| *value as f32).collect::<Vec<f32>>();
if let Ok(analysis) = pzt_processor.get_pzt_analysis(&pzt_values) {
debug!(
"spatial force: angle={:.2}°, magnitude={:.2}, dx={:.2}, dy={:.2}",
analysis.angle_deg, analysis.magnitude, analysis.planar_x, analysis.planar_y
);
if PztProcessor::should_report(&analysis) {
spatial_force = Some(HudSpatialForce {
angle_deg: analysis.angle_deg,
magnitude: analysis.magnitude,
confidence: analysis.confidence,
});
}
}
}
#[cfg(feature = "devkit")]
{
let summary = vals.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32);
push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force);
}
pending_sub_frame = Some(PendingSubFrame {
frame: frame.clone(),
values: vals,
spatial_force,
});
} else if let Some(packet) = frame.to_hud_packet(&mut chart_state, None) {
app.emit("hud_stream", packet)?;
}
} }
} }
Err(eskin_finger_sdk::error::SdkError::Timeout) => {}
Err(e) => {
break Err(format!("sample recv error: {e}"));
}
}
if let Err(e) = drain_events(&channels) {
break Err(e);
}
};
let _ = device.stop_stream();
result
}
fn drain_events(channels: &std::sync::Arc<eskin_finger_sdk::channel::ChannelManager>) -> Result<(), String> {
loop {
match channels.recv_event(0) {
Ok(DeviceEvent::IoError(msg)) => {
eprintln!("SDK stream io error: {msg}");
return Err(format!("stream io error: {msg}"));
}
Ok(_) => {}
Err(eskin_finger_sdk::error::SdkError::Timeout) => return Ok(()),
Err(eskin_finger_sdk::error::SdkError::ChannelClosed) => {
return Err("event channel closed".into());
}
Err(_) => return Ok(()),
} }
} }
Ok(())
} }
fn build_hud_packet_from_sample( fn build_display_values(
sample: &FingerSample,
chart_state: &mut HudChartState, chart_state: &mut HudChartState,
) -> Option<HudPacket> { values: &[i32],
let fz = sample.combined_forces.force.fz as f32; spatial_force: Option<HudSpatialForce>,
chart_state.record_summary(fz); ) -> Option<Vec<i32>> {
if !sample.raw_adcs.is_empty() { let summary = values.iter().copied().sum::<i32>();
let pressure: Vec<f32> = sample.raw_adcs.iter().map(|&v| v as f32).collect(); let force = raw_to_g1(summary as u32);
chart_state.record_pressure_matrix(&pressure); chart_state.record_summary(force as f32);
} chart_state.record_pressure_matrix(values);
Some(chart_state.build_snapshot()) chart_state.record_spatial_force(spatial_force);
Some(vec![summary])
}
#[cfg(feature = "devkit")]
fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) {
let devkit_state = app.state::<DevKitState>();
if !devkit_state.running.load(Ordering::Relaxed) {
return;
}
let (rows, cols) = infer_matrix_shape(values.len());
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let seq = timestamp_ms;
let matrix = values
.iter()
.map(|value| (*value).max(0) as u32)
.collect::<Vec<_>>();
devkit_state.push_frame(SensorFrame {
seq,
timestamp_ms,
rows,
cols,
matrix,
resultant_force,
dts_ms: dts_ms as u32,
});
}
#[cfg(feature = "devkit")]
fn infer_matrix_shape(len: usize) -> (u32, u32) {
if len == 84 {
return (12, 7);
}
if len == 0 {
return (0, 0);
}
let mut best = (len, 1);
let mut factor = 1usize;
while factor * factor <= len {
if len % factor == 0 {
best = (len / factor, factor);
}
factor += 1;
}
(best.0 as u32, best.1 as u32)
}
fn raw_to_g1(raw: u32) -> f64 {
const X: [u32; 12] = [
0, 84402, 117218, 140176, 159126, 175812, 191484, 208758, 224703, 252448, 302361, 352703,
];
const Y: [f64; 12] = [
0.0, 160.0, 260.0, 360.0, 460.0, 560.0, 660.0, 760.0, 860.0, 1060.0, 1560.0, 2060.0,
];
let n = X.len();
if raw <= X[0] {
return Y[0] / 100.0;
}
if raw >= X[n - 1] {
return Y[n - 1] / 100.0;
}
let mut left = 0;
let mut right = n - 1;
while left + 1 < right {
let mid = (left + right) / 2;
if raw < X[mid] {
right = mid;
} else {
left = mid;
}
}
let ratio = (raw - X[left]) as f64 / (X[right] - X[left]) as f64;
Y[left] / 100.0 + ratio * (Y[right] - Y[left]) / 100.0
} }

View File

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

View File

@@ -6,17 +6,21 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range"; import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range";
import ConfigPanel from "$lib/components/ConfigPanel.svelte"; import ConfigPanel from "$lib/components/ConfigPanel.svelte";
import ModelStage from "$lib/components/ModelStage.svelte";
import NeonBreakoutArena from "$lib/components/NeonBreakoutArena.svelte"; import NeonBreakoutArena from "$lib/components/NeonBreakoutArena.svelte";
import PressureMatrixViewer from "$lib/components/PressureMatrixViewer.svelte"; import PressureMatrixViewer from "$lib/components/PressureMatrixViewer.svelte";
import SignalChart from "$lib/components/SignalChart.svelte"; import SignalChart from "$lib/components/SignalChart.svelte";
import SpatialForcePanel from "$lib/components/SpatialForcePanel.svelte";
import SummaryCurve from "$lib/components/SummaryCurve.svelte"; import SummaryCurve from "$lib/components/SummaryCurve.svelte";
import type { import type {
HudColorMapOption, HudColorMapOption,
HudSignalPanel, HudSignalPanel,
HudSpatialForce,
HudSummary, HudSummary,
LocaleCode, LocaleCode,
MatrixDisplayMode, MatrixDisplayMode,
PressureColorMapPreset PressureColorMapPreset,
StageViewMode
} from "$lib/types/hud"; } from "$lib/types/hud";
export let locale: LocaleCode = "zh-CN"; export let locale: LocaleCode = "zh-CN";
@@ -24,6 +28,8 @@
export let rightPanels: HudSignalPanel[] = []; export let rightPanels: HudSignalPanel[] = [];
export let summary: HudSummary; export let summary: HudSummary;
export let pressureMatrix: number[] | null = null; export let pressureMatrix: number[] | null = null;
export let spatialForce: HudSpatialForce | null = null;
export let devkitSpatialForce: HudSpatialForce | null = null;
export let showConfigPanel = false; export let showConfigPanel = false;
export let configPanelTitle = ""; export let configPanelTitle = "";
export let configPanelHint = ""; export let configPanelHint = "";
@@ -41,6 +47,8 @@
export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX; export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
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 modelUrl = "/models/je-skin-model.glb";
export let replaySectionLabel = ""; export let replaySectionLabel = "";
export let replayPlayLabel = ""; export let replayPlayLabel = "";
export let replayPauseLabel = ""; export let replayPauseLabel = "";
@@ -53,7 +61,6 @@
export let replayProgress = 0; export let replayProgress = 0;
export let replayFileName = ""; export let replayFileName = "";
export let replayFrameInfo = ""; export let replayFrameInfo = "";
export let showPrecisionTestPanel = false;
export let sessionStartedAt: number = Date.now(); export let sessionStartedAt: number = Date.now();
let stagePlaneEl: HTMLDivElement | undefined; let stagePlaneEl: HTMLDivElement | undefined;
@@ -82,8 +89,7 @@
$: replayToggleButtonText = replayIsPlaying ? replayPauseLabel : replayPlayLabel; $: replayToggleButtonText = replayIsPlaying ? replayPauseLabel : replayPlayLabel;
$: replayProgressPercent = Math.round(Math.min(1, Math.max(0, replayProgress)) * 100); $: replayProgressPercent = Math.round(Math.min(1, Math.max(0, replayProgress)) * 100);
$: summaryCurveVisible = summary.points.length > 0 && summary.points.some((value) => Number.isFinite(value) && Math.abs(value) >= 0.0001); $: summaryCurveVisible = summary.points.length > 0 && summary.points.some((value) => Number.isFinite(value) && Math.abs(value) >= 0.0001);
$: splitMatrixTitle = locale === "zh-CN" ? "数字矩阵" : "Matrix"; $: isModelStage = stageViewMode === "model3d";
$: splitMatrixHint = locale === "zh-CN" ? "实时压力数据 / 数字矩阵" : "Live pressure matrix";
function toPxNumber(rawValue: string): number { function toPxNumber(rawValue: string): number {
const value = Number.parseFloat(rawValue); const value = Number.parseFloat(rawValue);
@@ -108,7 +114,7 @@
return; return;
} }
panelZoneTopPx = showPrecisionTestPanel ? 24 : 16; panelZoneTopPx = 16;
const panelZoneBottomPx = panelZoneEl ? toPxNumber(getComputedStyle(panelZoneEl).bottom) : 0; const panelZoneBottomPx = panelZoneEl ? toPxNumber(getComputedStyle(panelZoneEl).bottom) : 0;
const zoneHeight = Math.max(0, stagePlaneEl.clientHeight - panelZoneTopPx - panelZoneBottomPx); const zoneHeight = Math.max(0, stagePlaneEl.clientHeight - panelZoneTopPx - panelZoneBottomPx);
@@ -176,42 +182,11 @@
bind:this={stagePlaneEl} bind:this={stagePlaneEl}
style="--panel-zone-top-dyn: {panelZoneTopPx}px; --rail-scale-left: {leftRailScale}; --rail-scale-right: {rightRailScale};" style="--panel-zone-top-dyn: {panelZoneTopPx}px; --rail-scale-left: {leftRailScale}; --rail-scale-right: {rightRailScale};"
> >
{#if showPrecisionTestPanel} {#if isModelStage}
<div class="split-game-wrap"> <div class="canvas-wrap">
<section class="split-panel split-matrix-panel"> {#key modelUrl}
<header class="split-panel-head"> <ModelStage {locale} {modelUrl} />
<p>{splitMatrixTitle}</p> {/key}
<span>{splitMatrixHint}</span>
</header>
<div class="split-panel-body">
{#key `${matrixRows}x${matrixCols}:${colorMapPreset}:split`}
<PressureMatrixViewer
{summary}
{pressureMatrix}
{matrixRows}
{matrixCols}
{rangeMin}
{rangeMax}
{colorMapPreset}
{matrixDisplayMode}
{locale}
showStatsPanel={true}
/>
{/key}
</div>
</section>
<section class="split-panel split-breakout-panel">
<NeonBreakoutArena
{locale}
{pressureMatrix}
{matrixRows}
{matrixCols}
{rangeMin}
{rangeMax}
{colorMapPreset}
/>
</section>
</div> </div>
{:else} {:else}
<div class="canvas-wrap"> <div class="canvas-wrap">
@@ -232,7 +207,7 @@
</div> </div>
{/if} {/if}
{#if showConfigPanel && !showPrecisionTestPanel} {#if showConfigPanel && !isModelStage}
<div class="config-panel-wrap"> <div class="config-panel-wrap">
<ConfigPanel <ConfigPanel
bind:matrixRows bind:matrixRows
@@ -254,7 +229,7 @@
</div> </div>
{/if} {/if}
{#if !showPrecisionTestPanel} {#if !isModelStage}
<div class="panel-zone" bind:this={panelZoneEl}> <div class="panel-zone" bind:this={panelZoneEl}>
<aside class="side-rail left-rail"> <aside class="side-rail left-rail">
<div class="rail-stack" bind:this={leftStackEl}> <div class="rail-stack" bind:this={leftStackEl}>
@@ -303,6 +278,42 @@
</div> </div>
{/each} {/each}
<div
class="panel-motion-shell"
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
>
<SpatialForcePanel
{spatialForce}
{locale}
side="right"
panelIndex={rightPanels.length}
panelCode="ALG"
panelTitle={locale === "zh-CN" ? "本地切向力" : "Local Tangential"}
badgeLabel={locale === "zh-CN" ? "算法" : "ALGO"}
/>
</div>
<div
class="panel-motion-shell"
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
>
<SpatialForcePanel
spatialForce={devkitSpatialForce}
{locale}
side="right"
panelIndex={rightPanels.length + 1}
panelCode="DKT"
panelTitle={locale === "zh-CN" ? "DevKit 切向力" : "DevKit Tangential"}
badgeLabel="DEVKIT"
badgeTone="lime"
showMetrics={false}
requireMagnitude={false}
compactMetaText={locale === "zh-CN" ? "等待 DevKit 角度流" : "Waiting for DevKit angle"}
/>
</div>
{#if summaryCurveVisible && summarySide === "right"} {#if summaryCurveVisible && summarySide === "right"}
<div <div
class="panel-motion-shell" class="panel-motion-shell"
@@ -326,7 +337,7 @@
</div> </div>
{/if} {/if}
{#if replayHasData && !showPrecisionTestPanel} {#if replayHasData && !isModelStage}
<aside class="replay-floating-panel" class:is-left={replaySide === "left"} class:is-right={replaySide === "right"}> <aside class="replay-floating-panel" class:is-left={replaySide === "left"} class:is-right={replaySide === "right"}>
<div class="replay-panel-head"> <div class="replay-panel-head">
<div class="replay-panel-title-group"> <div class="replay-panel-title-group">
@@ -364,7 +375,7 @@
</aside> </aside>
{/if} {/if}
{#if !showPrecisionTestPanel} {#if !isModelStage}
<div class="stage-bottom-overlay"> <div class="stage-bottom-overlay">
<slot /> <slot />
</div> </div>

View File

@@ -4,8 +4,6 @@
ConnectionState, ConnectionState,
HudConfigLink, HudConfigLink,
HudNoticeTone, HudNoticeTone,
LocaleCode,
MatrixDisplayMode,
WindowControlAction WindowControlAction
} from "$lib/types/hud"; } from "$lib/types/hud";
@@ -31,9 +29,8 @@
export let configLinksLabel = ""; export let configLinksLabel = "";
export let configLinks: HudConfigLink[] = []; export let configLinks: HudConfigLink[] = [];
export let matrixViewLabel = ""; export let matrixViewLabel = "";
export let matrixViewNumericLabel = "";
export let matrixViewDotsLabel = ""; export let matrixViewDotsLabel = "";
export let matrixDisplayMode: MatrixDisplayMode = "dots"; export let stageViewMode: StageViewMode = "webgl";
export let connectActionLabel = ""; export let connectActionLabel = "";
export let disconnectActionLabel = ""; export let disconnectActionLabel = "";
export let exportActionLabel = ""; export let exportActionLabel = "";
@@ -56,6 +53,7 @@
localechange: LocaleCode; localechange: LocaleCode;
configlink: string; configlink: string;
matrixdisplaytoggle: boolean; matrixdisplaytoggle: boolean;
stagemodechange: StageViewMode;
portchange: string; portchange: string;
serialrefresh: void; serialrefresh: void;
serialconnect: string; serialconnect: string;
@@ -101,8 +99,8 @@
dispatch("configlink", linkId); dispatch("configlink", linkId);
} }
function emitMatrixDisplayToggle(): void { function emitStageModeChange(nextMode: StageViewMode): void {
dispatch("matrixdisplaytoggle", matrixDisplayMode !== "dots"); dispatch("stagemodechange", nextMode);
} }
function emitPortChange(event: Event): void { function emitPortChange(event: Event): void {
@@ -199,24 +197,6 @@
{/each} {/each}
</section> </section>
<section class="matrix-switch-wrap" aria-label={matrixViewLabel}>
<span class="matrix-switch-label">{matrixViewLabel}</span>
<button
type="button"
class="matrix-switch-btn"
class:is-active={matrixDisplayMode === "dots"}
role="switch"
aria-checked={matrixDisplayMode === "dots"}
aria-label={matrixViewDotsLabel}
on:click={emitMatrixDisplayToggle}
>
<span class="matrix-switch-track" aria-hidden="true">
<span class="matrix-switch-thumb"></span>
</span>
<span class="matrix-switch-copy">{matrixDisplayMode === "dots" ? matrixViewDotsLabel : matrixViewNumericLabel}</span>
</button>
</section>
<section class="state-card" aria-label={connectionLabel}> <section class="state-card" aria-label={connectionLabel}>
<span class="state-dot" class:ok={connectionTone === "ok"} class:warn={connectionTone === "warn"}></span> <span class="state-dot" class:ok={connectionTone === "ok"} class:warn={connectionTone === "warn"}></span>
<span class="state-label">{connectionLabel}</span> <span class="state-label">{connectionLabel}</span>
@@ -485,108 +465,6 @@
background: var(--panel-surface); background: var(--panel-surface);
} }
.matrix-switch-wrap {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-block-size: 2rem;
border: 1px solid var(--panel-line);
border-radius: 999px;
padding: 0.16rem 0.22rem 0.16rem 0.56rem;
background: var(--panel-surface);
}
.matrix-switch-label {
color: var(--panel-text-dim);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
white-space: nowrap;
}
.matrix-switch-btn {
display: inline-flex;
align-items: center;
gap: 0.42rem;
min-block-size: 1.62rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.26);
border-radius: 999px;
padding: 0.18rem 0.28rem 0.18rem 0.22rem;
background: rgb(var(--hud-surface-deep-rgb) / 0.84);
color: rgb(var(--hud-text-main-rgb) / 0.92);
cursor: pointer;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
background-color 180ms ease,
color 180ms ease;
}
.matrix-switch-btn:hover {
border-color: rgb(var(--hud-cyan-rgb) / 0.4);
}
.matrix-switch-btn.is-active {
border-color: rgb(var(--hud-cyan-rgb) / 0.5);
background:
linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.94), rgb(var(--hud-surface-rgb) / 0.9)),
radial-gradient(circle at 50% 0, rgb(var(--hud-cyan-rgb) / 0.12), transparent 60%);
box-shadow:
inset 0 0 0 1px rgb(var(--hud-text-main-rgb) / 0.05),
0 0 12px rgb(var(--hud-cyan-rgb) / 0.12);
}
.matrix-switch-track {
position: relative;
display: inline-flex;
align-items: center;
inline-size: 2.2rem;
block-size: 1.2rem;
border-radius: 999px;
padding: 0.14rem;
background: rgb(var(--hud-surface-rgb) / 0.9);
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-rgb) / 0.24);
transition:
background-color 180ms ease,
box-shadow 180ms ease;
}
.matrix-switch-btn.is-active .matrix-switch-track {
background: rgb(var(--hud-cyan-rgb) / 0.18);
box-shadow: inset 0 0 0 1px rgb(var(--hud-cyan-rgb) / 0.18);
}
.matrix-switch-thumb {
inline-size: 0.92rem;
block-size: 0.92rem;
border-radius: 50%;
background: rgb(var(--hud-text-main-rgb) / 0.96);
box-shadow:
0 1px 4px rgb(0 0 0 / 0.26),
0 0 10px rgb(var(--hud-text-main-rgb) / 0.12);
transform: translateX(0);
transition:
transform 180ms ease,
background-color 180ms ease,
box-shadow 180ms ease;
}
.matrix-switch-btn.is-active .matrix-switch-thumb {
transform: translateX(0.96rem);
background: rgb(var(--hud-cyan-rgb) / 0.96);
box-shadow:
0 1px 4px rgb(0 0 0 / 0.26),
0 0 12px rgb(var(--hud-cyan-rgb) / 0.22);
}
.matrix-switch-copy {
font-size: 0.74rem;
letter-spacing: 0.04em;
white-space: nowrap;
line-height: 1;
}
.state-dot { .state-dot {
inline-size: 0.55rem; inline-size: 0.55rem;
block-size: 0.55rem; block-size: 0.55rem;

View File

@@ -0,0 +1,469 @@
<script lang="ts">
import { onMount } from "svelte";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
import type { LocaleCode } from "$lib/types/hud";
type ModelLoadState = "loading" | "ready" | "missing" | "error";
export let locale: LocaleCode = "zh-CN";
export let modelUrl = "/models/je-skin-model.glb";
let rootEl: HTMLDivElement | undefined;
let canvasEl: HTMLCanvasElement | undefined;
let loadState: ModelLoadState = "loading";
let loadProgress = 0;
let loadError = "";
const FLOOR_Y = -1.15;
const MODEL_FLOOR_CLEARANCE = 0.035;
const MODEL_TARGET_HEIGHT = 8.4;
const MODEL_MIN_SCALE = 0.02;
const MODEL_MAX_SCALE = 80;
const CAMERA_DISTANCE_FACTOR = 1.35;
const CAMERA_DISTANCE_MIN = 7.5;
const CAMERA_DISTANCE_MAX = 24;
$: copy =
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 {
object.traverse((child) => {
const mesh = child as THREE.Mesh;
if (mesh.geometry) {
mesh.geometry.dispose();
}
const material = mesh.material;
if (Array.isArray(material)) {
for (const item of material) {
item.dispose();
}
} else if (material) {
material.dispose();
}
});
}
function buildPlaceholderModel(): THREE.Group {
const group = new THREE.Group();
const cyan = new THREE.Color(0x5ee7ff);
const lime = new THREE.Color(0xa6ff7a);
const platform = new THREE.Mesh(
new THREE.CylinderGeometry(5.8, 6.7, 0.36, 96),
new THREE.MeshStandardMaterial({
color: 0x0c1824,
emissive: 0x07131f,
metalness: 0.62,
roughness: 0.34
})
);
platform.position.y = 0.18;
group.add(platform);
const ringGeometry = new THREE.TorusGeometry(4.35, 0.035, 10, 128);
const ringMaterial = new THREE.MeshBasicMaterial({ color: cyan, transparent: true, opacity: 0.78 });
for (let index = 0; index < 3; index += 1) {
const ring = new THREE.Mesh(ringGeometry, ringMaterial);
ring.position.y = 0.52 + index * 0.52;
ring.rotation.x = Math.PI / 2;
group.add(ring);
}
const coreMaterial = new THREE.MeshStandardMaterial({
color: 0x1b2a38,
emissive: 0x0a2632,
metalness: 0.48,
roughness: 0.42,
transparent: true,
opacity: 0.72
});
const core = new THREE.Mesh(new THREE.BoxGeometry(2.2, 3.4, 1.1), coreMaterial);
core.position.y = 2.4;
core.rotation.y = -0.36;
group.add(core);
const sensorMaterial = new THREE.MeshBasicMaterial({ color: lime, transparent: true, opacity: 0.88 });
for (let index = 0; index < 7; index += 1) {
const bead = new THREE.Mesh(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);
}
return group;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeObjectToStage(object: THREE.Object3D): THREE.Box3 {
object.updateMatrixWorld(true);
let bounds = new THREE.Box3().setFromObject(object);
const size = bounds.getSize(new THREE.Vector3());
const currentHeight = Math.max(size.y, 0.001);
const scale = clamp(MODEL_TARGET_HEIGHT / currentHeight, MODEL_MIN_SCALE, MODEL_MAX_SCALE);
object.scale.multiplyScalar(scale);
object.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(object);
const center = bounds.getCenter(new THREE.Vector3());
object.position.x -= center.x;
object.position.z -= center.z;
object.position.y += FLOOR_Y + MODEL_FLOOR_CLEARANCE - bounds.min.y;
object.updateMatrixWorld(true);
return new THREE.Box3().setFromObject(object);
}
function frameObject(object: THREE.Object3D, camera: THREE.PerspectiveCamera, controls: OrbitControls): void {
const bounds = normalizeObjectToStage(object);
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 1);
const distance = clamp(maxAxis * CAMERA_DISTANCE_FACTOR, CAMERA_DISTANCE_MIN, CAMERA_DISTANCE_MAX);
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();
controls.target.set(0, targetY, 0);
controls.minDistance = Math.max(distance * 0.32, 2);
controls.maxDistance = Math.max(distance * 2.5, 12);
controls.update();
}
onMount(() => {
if (!rootEl || !canvasEl) {
return;
}
const renderer = new THREE.WebGLRenderer({
canvas: canvasEl,
antialias: true,
alpha: true,
powerPreference: "high-performance"
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x03070d, 1);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x03070d, 0.028);
const camera = new THREE.PerspectiveCamera(38, 1, 0.05, 600);
camera.position.set(8, 6, 9);
const controls = new OrbitControls(camera, canvasEl);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 2.4;
controls.maxDistance = 32;
controls.target.set(0, FLOOR_Y + 3.2, 0);
const labGroup = new THREE.Group();
scene.add(labGroup);
const grid = new THREE.GridHelper(42, 42, 0x63e6ff, 0x123047);
grid.position.y = FLOOR_Y;
const gridMaterial = grid.material;
if (Array.isArray(gridMaterial)) {
for (const material of gridMaterial) {
material.transparent = true;
material.opacity = 0.28;
}
} else {
gridMaterial.transparent = true;
gridMaterial.opacity = 0.28;
}
labGroup.add(grid);
const backGrid = new THREE.GridHelper(42, 42, 0x5ee7ff, 0x0c2436);
backGrid.position.set(0, 9.5, -17);
backGrid.rotation.x = Math.PI / 2;
const backGridMaterial = backGrid.material;
if (Array.isArray(backGridMaterial)) {
for (const material of backGridMaterial) {
material.transparent = true;
material.opacity = 0.12;
}
} else {
backGridMaterial.transparent = true;
backGridMaterial.opacity = 0.12;
}
labGroup.add(backGrid);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(42, 42),
new THREE.MeshStandardMaterial({
color: 0x050c14,
metalness: 0.28,
roughness: 0.64,
transparent: true,
opacity: 0.72
})
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = FLOOR_Y - 0.018;
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();
scene.add(activeModel);
frameObject(activeModel, camera, controls);
const loader = new GLTFLoader();
loader.load(
modelUrl,
(gltf: GLTF) => {
scene.remove(activeModel);
disposeObject3D(activeModel);
activeModel = gltf.scene;
activeModel.traverse((child) => {
const mesh = child as THREE.Mesh;
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = true;
}
});
scene.add(activeModel);
frameObject(activeModel, camera, controls);
loadState = "ready";
loadProgress = 100;
},
(event) => {
if (event.total > 0) {
loadProgress = (event.loaded / event.total) * 100;
} else {
loadProgress = 12;
}
},
(error) => {
const message = error instanceof Error ? error.message : String(error);
loadError = message || "Unknown model loader error";
loadState = message.toLowerCase().includes("404") ? "missing" : "error";
loadProgress = 0;
}
);
const resize = () => {
if (!rootEl) {
return;
}
const width = rootEl.clientWidth;
const height = rootEl.clientHeight;
if (width <= 0 || height <= 0) {
return;
}
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(rootEl);
renderer.setAnimationLoop((timestamp) => {
const seconds = timestamp / 1000;
labGroup.position.y = Math.sin(seconds * 0.75) * 0.015;
if (loadState !== "ready") {
activeModel.rotation.y = seconds * 0.32;
}
controls.update();
renderer.render(scene, camera);
});
return () => {
resizeObserver.disconnect();
renderer.setAnimationLoop(null);
controls.dispose();
disposeObject3D(activeModel);
disposeObject3D(labGroup);
renderer.dispose();
};
});
</script>
<div class="model-stage" bind:this={rootEl}>
<canvas class="model-canvas" bind:this={canvasEl} aria-label={copy.title}></canvas>
<div class="model-vignette" 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>
<style>
.model-stage {
position: absolute;
inset: 0;
overflow: hidden;
background:
radial-gradient(circle at 52% 62%, 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%);
}
.model-canvas,
.model-vignette,
.model-scanlines {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
}
.model-canvas {
display: block;
}
.model-vignette,
.model-scanlines {
pointer-events: none;
}
.model-vignette {
background:
linear-gradient(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%);
}
.model-scanlines {
opacity: 0.32;
background:
repeating-linear-gradient(180deg, rgb(94 231 255 / 0.045) 0, rgb(94 231 255 / 0.045) 1px, transparent 1px, transparent 4px);
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>

View File

@@ -0,0 +1,523 @@
<script lang="ts">
import type { HudSpatialForce } from "$lib/types/hud";
export let spatialForce: HudSpatialForce | null = null;
export let side: "left" | "right" = "right";
export let panelIndex = 0;
export let locale: "zh-CN" | "en-US" = "zh-CN";
export let panelCode = "TAN";
export let panelTitle = "";
export let badgeLabel = "";
export let badgeTone: "cyan" | "lime" | "orange" = "cyan";
export let showMetrics = true;
export let requireMagnitude = true;
export let compactMetaText = "";
function formatValue(value: number | null, digits = 1): string {
if (value === null || !Number.isFinite(value)) {
return "--";
}
return value.toFixed(digits);
}
function normalizeAngle(value: number): number {
return ((value % 360) + 360) % 360;
}
function shortestAngleDelta(from: number, to: number): number {
const delta = ((to - from + 540) % 360) - 180;
return delta === -180 ? 180 : delta;
}
const jumpAngleThresholdDeg = 72;
let visualAngleDeg = 0;
let previousRawAngleDeg: number | null = null;
let snapVector = false;
let snapResetFrame: number | null = null;
function setSnapVector(): void {
snapVector = true;
if (typeof window === "undefined") {
return;
}
if (snapResetFrame !== null) {
window.cancelAnimationFrame(snapResetFrame);
}
snapResetFrame = window.requestAnimationFrame(() => {
snapVector = false;
snapResetFrame = null;
});
}
function updateVisualAngle(rawAngleDeg: number, active: boolean): void {
if (!active) {
previousRawAngleDeg = null;
visualAngleDeg = 0;
return;
}
if (previousRawAngleDeg === null) {
previousRawAngleDeg = rawAngleDeg;
visualAngleDeg = rawAngleDeg;
return;
}
const delta = shortestAngleDelta(previousRawAngleDeg, rawAngleDeg);
if (Math.abs(delta) < 0.001) {
return;
}
if (Math.abs(delta) >= jumpAngleThresholdDeg) {
setSnapVector();
}
visualAngleDeg += delta;
previousRawAngleDeg = rawAngleDeg;
}
$: i18n =
locale === "zh-CN"
? {
title: "切向力方向",
waiting: "等待数据",
angle: "ANGLE",
heading: "方向角",
strength: "强度",
confidence: "置信度"
}
: {
title: "Tangential Direction",
waiting: "Waiting",
angle: "ANGLE",
heading: "Heading",
strength: "Strength",
confidence: "Confidence"
};
$: resolvedTitle = panelTitle || i18n.title;
$: resolvedBadgeLabel = badgeLabel || i18n.angle;
$: resolvedCompactMetaText =
compactMetaText || (locale === "zh-CN" ? "仅使用角度流" : "Angle stream only");
$: hasData =
spatialForce !== null &&
Number.isFinite(spatialForce.angleDeg) &&
(!requireMagnitude || Number.isFinite(spatialForce.magnitude));
$: angleDeg = hasData ? normalizeAngle(spatialForce?.angleDeg ?? 0) : 0;
$: updateVisualAngle(angleDeg, hasData);
$: magnitude = hasData ? spatialForce?.magnitude ?? 0 : null;
$: confidence = hasData ? (spatialForce?.confidence ?? 0) * 100 : null;
</script>
<article
class="signal-panel spatial-panel side-{side}"
class:is-empty={!hasData}
aria-hidden={false}
style="--panel-index: {panelIndex};"
>
<header class="panel-head">
<div class="head-text">
<p class="panel-code">{panelCode}</p>
<p class="panel-title">{resolvedTitle}</p>
</div>
<div class="icon-layer" aria-hidden="true">
<span class={`icon-chip tone-${badgeTone}`}>{resolvedBadgeLabel}</span>
</div>
</header>
<div class="panel-body">
<div class="compass-stage">
<div class="compass-core">
<div class="compass-ring compass-ring-outer"></div>
<div class="compass-ring compass-ring-inner"></div>
<div class="compass-axis axis-horizontal"></div>
<div class="compass-axis axis-vertical"></div>
{#if hasData}
<div
class="compass-vector"
class:is-snap={snapVector}
style="transform: translateY(-50%) rotate({-visualAngleDeg}deg);"
>
<span class="vector-shaft"></span>
<span class="vector-head"></span>
</div>
{/if}
<div class="compass-center"></div>
<span class="compass-label label-top">90</span>
<span class="compass-label label-right">0</span>
<span class="compass-label label-bottom">270</span>
<span class="compass-label label-left">180</span>
</div>
{#if !hasData}
<div class="empty-state">
<span>{i18n.waiting}</span>
</div>
{/if}
</div>
<div class="angle-stage">
<p class="angle-label">{i18n.heading}</p>
{#if showMetrics}
<p class="angle-meta">{i18n.strength}: {formatValue(magnitude, 2)}</p>
<p class="angle-meta">{i18n.confidence}: {hasData ? `${formatValue(confidence, 0)}%` : "--"}</p>
{:else}
<p class="angle-meta">{resolvedCompactMetaText}</p>
<p class="angle-meta">{hasData ? (locale === "zh-CN" ? "实时对比中" : "Live comparison") : "--"}</p>
{/if}
</div>
</div>
</article>
<style>
.signal-panel {
--offset-x: 12%;
--enter-ms: 1800ms;
--fade-ms: 1000ms;
overflow: hidden;
inline-size: min(100%, clamp(34rem, 44vw, 44rem));
justify-self: start;
display: grid;
grid-template-rows: auto 1fr;
gap: 0.68rem;
padding: 0.88rem 0.96rem 1rem;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.42);
border-radius: 0.92rem;
background:
linear-gradient(160deg, rgb(var(--hud-surface-alt-rgb) / 0.76) 0%, rgb(var(--hud-surface-rgb) / 0.62) 48%, rgb(var(--hud-surface-deep-rgb) / 0.76) 100%),
radial-gradient(circle at 12% 0, rgb(var(--hud-glow-rgb) / 0.1), transparent 40%);
box-shadow:
inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08),
inset 0 -24px 32px rgb(0 0 0 / 0.48),
0 0 14px rgb(var(--hud-glow-rgb) / 0.14);
opacity: 1;
transform: translateX(0) scale(1) rotate(0);
transition:
opacity var(--fade-ms) cubic-bezier(0.18, 0.88, 0.3, 1),
transform var(--enter-ms) cubic-bezier(0.2, 0.9, 0.28, 1),
border-color 460ms ease,
filter 760ms ease;
transition-delay: calc(var(--panel-index) * 140ms);
}
.signal-panel.side-left {
--offset-x: -132%;
}
.signal-panel.side-right {
--offset-x: 132%;
justify-self: end;
}
.spatial-panel.is-empty {
opacity: 0.82;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.4rem;
margin-block-end: 0;
}
.head-text {
min-width: 0;
}
.panel-code {
margin: 0;
font-size: 0.63rem;
color: rgb(var(--hud-text-dim-rgb) / 0.88);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.panel-title {
margin: 0.12rem 0 0;
font-size: 1.08rem;
color: rgb(var(--hud-text-main-rgb) / 0.96);
letter-spacing: 0.05em;
}
.icon-layer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.26rem;
}
.icon-chip {
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.44);
border-radius: 999px;
padding: 0.08rem 0.36rem;
font-size: 0.58rem;
letter-spacing: 0.08em;
color: rgb(var(--hud-text-main-rgb) / 0.94);
background: rgb(var(--hud-surface-rgb) / 0.66);
}
.icon-chip.tone-cyan {
border-color: rgb(var(--hud-cyan-rgb) / 0.54);
}
.icon-chip.tone-lime {
border-color: rgb(var(--hud-lime-rgb) / 0.54);
}
.icon-chip.tone-orange {
border-color: rgb(var(--hud-orange-rgb) / 0.54);
}
.panel-body {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(10rem, 0.9fr);
gap: 0.72rem;
block-size: clamp(12rem, 15.5vw, 15rem);
min-block-size: clamp(12rem, 15.5vw, 15rem);
}
.compass-stage {
position: relative;
min-block-size: 0;
overflow: hidden;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.32);
border-radius: 0.62rem;
background:
linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.68), rgb(var(--hud-surface-deep-rgb) / 0.78)),
radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.09), transparent 45%);
display: grid;
place-items: center;
}
.compass-core {
position: relative;
inline-size: min(72%, 13rem);
aspect-ratio: 1;
}
.compass-ring,
.compass-axis,
.compass-center,
.compass-vector {
position: absolute;
top: 50%;
left: 50%;
}
.compass-ring {
border-radius: 50%;
transform: translate(-50%, -50%);
}
.compass-ring-outer {
inline-size: 100%;
block-size: 100%;
border: 1px solid rgb(var(--hud-cyan-rgb) / 0.28);
box-shadow: 0 0 18px rgb(var(--hud-glow-rgb) / 0.08);
}
.compass-ring-inner {
inline-size: 62%;
block-size: 62%;
border: 1px dashed rgb(var(--hud-border-strong-rgb) / 0.3);
}
.compass-axis {
background: rgb(var(--hud-border-strong-rgb) / 0.18);
transform: translate(-50%, -50%);
}
.axis-horizontal {
inline-size: 86%;
block-size: 1px;
}
.axis-vertical {
inline-size: 1px;
block-size: 86%;
}
.compass-vector {
inline-size: 42%;
block-size: 0.9rem;
transform-origin: 0 50%;
transition: transform 220ms ease;
}
.compass-vector.is-snap {
transition-duration: 0ms;
}
.vector-shaft {
position: absolute;
top: 50%;
left: 0;
right: 0.7rem;
block-size: 2px;
transform: translateY(-50%);
border-radius: 999px;
background: linear-gradient(90deg, rgb(var(--hud-cyan-rgb) / 0.18), rgb(var(--hud-cyan-rgb) / 0.96));
box-shadow: 0 0 14px rgb(var(--hud-cyan-rgb) / 0.2);
}
.vector-head {
position: absolute;
top: 50%;
right: 0;
inline-size: 0;
block-size: 0;
transform: translateY(-50%);
border-top: 0.36rem solid transparent;
border-bottom: 0.36rem solid transparent;
border-left: 0.7rem solid rgb(var(--hud-lime-rgb) / 0.96);
filter: drop-shadow(0 0 8px rgb(var(--hud-lime-rgb) / 0.24));
}
.compass-center {
inline-size: 0.56rem;
block-size: 0.56rem;
transform: translate(-50%, -50%);
border-radius: 50%;
background: rgb(var(--hud-text-main-rgb) / 0.92);
box-shadow: 0 0 10px rgb(var(--hud-text-main-rgb) / 0.12);
}
.compass-label {
position: absolute;
font-size: 0.58rem;
color: rgb(var(--hud-text-dim-rgb) / 0.8);
letter-spacing: 0.08em;
}
.label-top {
top: -0.9rem;
left: 50%;
transform: translateX(-50%);
}
.label-right {
top: 50%;
right: -1rem;
transform: translateY(-50%);
}
.label-bottom {
bottom: -0.9rem;
left: 50%;
transform: translateX(-50%);
}
.label-left {
top: 50%;
left: -1.35rem;
transform: translateY(-50%);
}
.empty-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgb(var(--hud-text-dim-rgb) / 0.76);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: linear-gradient(180deg, rgb(var(--hud-surface-deep-rgb) / 0.06), rgb(var(--hud-surface-deep-rgb) / 0.18));
}
.angle-stage {
border: 1px solid rgb(var(--hud-border-rgb) / 0.26);
border-radius: 0.62rem;
padding: 0.9rem 0.85rem;
block-size: 100%;
min-block-size: 0;
overflow: hidden;
background:
linear-gradient(180deg, rgb(var(--hud-surface-rgb) / 0.72), rgb(var(--hud-surface-deep-rgb) / 0.84)),
radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.05), transparent 58%);
display: grid;
grid-template-rows: auto auto auto;
align-content: center;
justify-items: start;
gap: 0.36rem;
}
.angle-label {
margin: 0;
color: rgb(var(--hud-text-dim-rgb) / 0.82);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.angle-meta {
margin: 0;
inline-size: 10rem;
min-block-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: rgb(var(--hud-text-dim-rgb) / 0.84);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
}
@media (max-width: 1180px) {
.signal-panel {
inline-size: min(100%, clamp(28rem, 40vw, 38rem));
}
}
@media (max-height: 900px) {
.signal-panel {
inline-size: min(100%, clamp(28rem, 38vw, 36rem));
padding: 0.7rem 0.76rem 0.8rem;
}
}
@media (max-height: 760px) {
.signal-panel {
inline-size: min(100%, clamp(24rem, 34vw, 30rem));
padding: 0.62rem 0.68rem 0.72rem;
gap: 0.48rem;
}
.panel-body {
block-size: clamp(9rem, 10vw, 10.8rem);
min-block-size: clamp(9rem, 10vw, 10.8rem);
}
}
@media (max-height: 680px) {
.signal-panel {
inline-size: min(100%, clamp(20rem, 28vw, 26rem));
padding: 0.52rem 0.58rem 0.6rem;
gap: 0.36rem;
}
}
@media (max-width: 900px) {
.signal-panel {
inline-size: 100%;
}
.panel-body {
grid-template-columns: 1fr;
block-size: auto;
min-block-size: auto;
}
.compass-core {
inline-size: min(58vw, 12rem);
}
}
</style>

View File

@@ -3,6 +3,7 @@ export type LocaleCode = "zh-CN" | "en-US";
export type WindowControlAction = "minimize" | "toggle-maximize" | "close"; export type WindowControlAction = "minimize" | "toggle-maximize" | "close";
export type ConnectionState = "online" | "connecting" | "offline"; export type ConnectionState = "online" | "connecting" | "offline";
export type StageViewMode = "webgl" | "model3d";
export type StageStatusTone = "ok" | "warn" | "idle"; export type StageStatusTone = "ok" | "warn" | "idle";
export type HudNoticeTone = "ok" | "warn" | "info"; export type HudNoticeTone = "ok" | "warn" | "info";
@@ -40,11 +41,18 @@ export interface HudSignalPanel {
max: number | null; max: number | null;
} }
export interface HudSpatialForce {
angleDeg: number;
magnitude: number;
confidence: number;
}
export interface HudPacket { export interface HudPacket {
ts: number; ts: number;
panels: HudSignalPanel[]; panels: HudSignalPanel[];
summary: HudSummary; summary: HudSummary;
pressureMatrix: number[] | null; pressureMatrix: number[] | null;
spatialForce: HudSpatialForce | null;
} }
export interface HudSummary { export interface HudSummary {
@@ -86,6 +94,9 @@ export interface HudCopy {
matrixViewLabel: string; matrixViewLabel: string;
matrixViewNumericLabel: string; matrixViewNumericLabel: string;
matrixViewDotsLabel: string; matrixViewDotsLabel: string;
stageModeLabel: string;
stageModeWebglLabel: string;
stageModeModelLabel: string;
resetConfigLabel: string; resetConfigLabel: string;
applyLiveHint: string; applyLiveHint: string;
runtimeReady: string; runtimeReady: string;

View File

@@ -22,17 +22,18 @@
HudConfigLink, HudConfigLink,
HudNoticeTone, HudNoticeTone,
HudPacket, HudPacket,
HudSpatialForce,
PressureColorMapPreset, PressureColorMapPreset,
HudSignalPanel, HudSignalPanel,
HudSignalSeries, HudSignalSeries,
HudSummary, HudSummary,
LocaleCode, LocaleCode,
MatrixDisplayMode,
SerialConnectResult, SerialConnectResult,
SerialExportResult, SerialExportResult,
SerialRecordStateResult, SerialRecordStateResult,
SerialImportResult, SerialImportResult,
SignalTone, SignalTone,
StageViewMode,
WindowControlAction WindowControlAction
} from "$lib/types/hud"; } from "$lib/types/hud";
@@ -44,6 +45,13 @@
dtsMs: number; dtsMs: number;
} }
interface DevKitPztAngleEvent {
seq: number;
timestampMs: number;
dtsMs: number;
angle: number;
}
const copyByLocale: Record<LocaleCode, HudCopy> = { const copyByLocale: Record<LocaleCode, HudCopy> = {
"zh-CN": { "zh-CN": {
appName: "JE-Skin", appName: "JE-Skin",
@@ -60,8 +68,10 @@
rangeMaxLabel: "最大值", rangeMaxLabel: "最大值",
colorMapLabel: "映射颜色", colorMapLabel: "映射颜色",
matrixViewLabel: "矩阵模式", matrixViewLabel: "矩阵模式",
matrixViewNumericLabel: "数字矩阵",
matrixViewDotsLabel: "点矩阵", matrixViewDotsLabel: "点矩阵",
stageModeLabel: "渲染模式",
stageModeWebglLabel: "WebGL",
stageModeModelLabel: "3D 模型",
resetConfigLabel: "恢复默认", resetConfigLabel: "恢复默认",
applyLiveHint: "实时生效 / 矩阵尺寸变更将重建 viewer", applyLiveHint: "实时生效 / 矩阵尺寸变更将重建 viewer",
runtimeReady: "WEBGL2 READY", runtimeReady: "WEBGL2 READY",
@@ -119,8 +129,10 @@
rangeMaxLabel: "Max", rangeMaxLabel: "Max",
colorMapLabel: "Color Map", colorMapLabel: "Color Map",
matrixViewLabel: "Matrix Mode", matrixViewLabel: "Matrix Mode",
matrixViewNumericLabel: "Numeric",
matrixViewDotsLabel: "Dots", matrixViewDotsLabel: "Dots",
stageModeLabel: "Render Mode",
stageModeWebglLabel: "WebGL",
stageModeModelLabel: "3D Model",
resetConfigLabel: "Reset", resetConfigLabel: "Reset",
applyLiveHint: "Live apply / size changes recreate the viewer", applyLiveHint: "Live apply / size changes recreate the viewer",
runtimeReady: "WEBGL2 READY", runtimeReady: "WEBGL2 READY",
@@ -216,17 +228,18 @@
let isWindowMaximized = false; let isWindowMaximized = false;
let activeConfigLinkId = "stream-on"; let activeConfigLinkId = "stream-on";
let isConfigPanelOpen = false; let isConfigPanelOpen = false;
let isPrecisionTestOpen = false;
let hasSignalData = false; let hasSignalData = false;
let signalPanels: HudSignalPanel[] = buildInactivePanels(); let signalPanels: HudSignalPanel[] = buildInactivePanels();
let summary: HudSummary = buildEmptySummary(); let summary: HudSummary = buildEmptySummary();
let pressureMatrix: number[] | null = null; let pressureMatrix: number[] | null = null;
let spatialForce: HudSpatialForce | null = null;
let devkitSpatialForce: HudSpatialForce | null = null;
let matrixRows = 12; let matrixRows = 12;
let matrixCols = 7; let matrixCols = 7;
let rangeMin = DEFAULT_PRESSURE_RANGE_MIN; let rangeMin = DEFAULT_PRESSURE_RANGE_MIN;
let rangeMax = DEFAULT_PRESSURE_RANGE_MAX; let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
let colorMapPreset: PressureColorMapPreset = "emerald"; let colorMapPreset: PressureColorMapPreset = "emerald";
let matrixDisplayMode: MatrixDisplayMode = "dots"; let stageViewMode: StageViewMode = "webgl";
let replayFrames: ReplayFrame[] = []; let replayFrames: ReplayFrame[] = [];
let replayCurrentIndex = 0; let replayCurrentIndex = 0;
let replayHasDisplayedFrame = false; let replayHasDisplayedFrame = false;
@@ -260,6 +273,7 @@
rowsKept: number; rowsKept: number;
} | null = null; } | null = null;
let devkitStatusTimer: number | null = null; let devkitStatusTimer: number | null = null;
let devkitSpatialForceClearTimer: number | null = null;
let sessionStartedAt: number = Date.now(); let sessionStartedAt: number = Date.now();
$: uiCopy = copyByLocale[locale]; $: uiCopy = copyByLocale[locale];
@@ -267,7 +281,6 @@
locale, locale,
activeConfigLinkId, activeConfigLinkId,
isConfigPanelOpen, isConfigPanelOpen,
isPrecisionTestOpen,
devkitEnabled, devkitEnabled,
isDevKitConfigOpen isDevKitConfigOpen
); );
@@ -287,6 +300,31 @@
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
} }
function clearDevkitSpatialForce(): void {
devkitSpatialForce = null;
if (devkitSpatialForceClearTimer != null && typeof window !== "undefined") {
window.clearTimeout(devkitSpatialForceClearTimer);
devkitSpatialForceClearTimer = null;
}
hasSignalData = signalPanels.length > 0 || summary.points.length > 0 || spatialForce !== null;
}
function scheduleDevkitSpatialForceClear(): void {
if (typeof window === "undefined") {
return;
}
if (devkitSpatialForceClearTimer != null) {
window.clearTimeout(devkitSpatialForceClearTimer);
}
devkitSpatialForceClearTimer = window.setTimeout(() => {
devkitSpatialForce = null;
devkitSpatialForceClearTimer = null;
hasSignalData = signalPanels.length > 0 || summary.points.length > 0 || spatialForce !== null;
}, 420);
}
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
@@ -709,6 +747,8 @@
function resetReplayVisualState(): void { function resetReplayVisualState(): void {
pressureMatrix = buildZeroMatrix(); pressureMatrix = buildZeroMatrix();
spatialForce = null;
clearDevkitSpatialForce();
signalPanels = buildInactivePanels(); signalPanels = buildInactivePanels();
summary = buildEmptySummary(); summary = buildEmptySummary();
hasSignalData = false; hasSignalData = false;
@@ -744,6 +784,8 @@
replayHasDisplayedFrame = true; replayHasDisplayedFrame = true;
replayProgress = replayFrames.length > 1 ? safeIndex / (replayFrames.length - 1) : 1; replayProgress = replayFrames.length > 1 ? safeIndex / (replayFrames.length - 1) : 1;
pressureMatrix = frameValuesToMatrix(replayFrames[safeIndex].values); pressureMatrix = frameValuesToMatrix(replayFrames[safeIndex].values);
spatialForce = null;
clearDevkitSpatialForce();
signalPanels = buildInactivePanels(); signalPanels = buildInactivePanels();
summary = buildReplaySummaryAt(safeIndex); summary = buildReplaySummaryAt(safeIndex);
hasSignalData = true; hasSignalData = true;
@@ -998,7 +1040,12 @@
summary = packet.summary; summary = packet.summary;
} }
pressureMatrix = packet.pressureMatrix; pressureMatrix = packet.pressureMatrix;
hasSignalData = signalPanels.length > 0 || packet.summary.points.length > 0; spatialForce = packet.spatialForce ?? null;
hasSignalData =
signalPanels.length > 0 ||
packet.summary.points.length > 0 ||
spatialForce !== null ||
devkitSpatialForce !== null;
} }
function clearHudPanels(): void { function clearHudPanels(): void {
@@ -1006,17 +1053,19 @@
signalPanels = buildInactivePanels(); signalPanels = buildInactivePanels();
summary = buildEmptySummary(); summary = buildEmptySummary();
pressureMatrix = null; pressureMatrix = null;
spatialForce = null;
clearDevkitSpatialForce();
} }
function startMockFeed(push: (packet: HudPacket) => void): () => void { function startMockFeed(push: (packet: HudPacket) => void): () => void {
let panels = buildInactivePanels(); let panels = buildInactivePanels();
let summaryValue = buildSummary(createSummaryPoints(randomBetween(480, 1440))); let summaryValue = buildSummary(createSummaryPoints(randomBetween(480, 1440)));
push({ ts: Date.now(), panels, summary: summaryValue, pressureMatrix: null }); push({ ts: Date.now(), panels, summary: summaryValue, pressureMatrix: null, spatialForce: null });
const timerId = window.setInterval(() => { const timerId = window.setInterval(() => {
summaryValue = evolveSummary(summaryValue); summaryValue = evolveSummary(summaryValue);
push({ ts: Date.now(), panels, summary: summaryValue, pressureMatrix: null }); push({ ts: Date.now(), panels, summary: summaryValue, pressureMatrix: null, spatialForce: null });
}, signalRenderTickMs); }, signalRenderTickMs);
return () => { return () => {
@@ -1034,7 +1083,6 @@
currentLocale: LocaleCode, currentLocale: LocaleCode,
activeId: string, activeId: string,
isSettingsOpen: boolean, isSettingsOpen: boolean,
isPrecisionOpen: boolean,
isDevKitEnabled: boolean, isDevKitEnabled: boolean,
isDevKitOpen: boolean isDevKitOpen: boolean
): HudConfigLink[] { ): HudConfigLink[] {
@@ -1043,13 +1091,11 @@
? { ? {
streamOn: "打开", streamOn: "打开",
streamOff: "关闭", streamOff: "关闭",
precisionTest: "游戏",
settings: "参数" settings: "参数"
} }
: { : {
streamOn: "Open", streamOn: "Open",
streamOff: "Close", streamOff: "Close",
precisionTest: "Game",
settings: "Setup" settings: "Setup"
}; };
const devkitLabel = currentLocale === "zh-CN" ? "开发工具" : "DevKit"; const devkitLabel = currentLocale === "zh-CN" ? "开发工具" : "DevKit";
@@ -1067,12 +1113,6 @@
tone: "orange", tone: "orange",
active: activeId === "stream-off" active: activeId === "stream-off"
}, },
{
id: "precision-test",
label: labels.precisionTest,
tone: "lime",
active: isPrecisionOpen
},
{ {
id: "settings", id: "settings",
label: labels.settings, label: labels.settings,
@@ -1643,28 +1683,19 @@
} }
function handleConfigLink(event: CustomEvent<string>): void { function handleConfigLink(event: CustomEvent<string>): void {
if (event.detail === "precision-test") {
isPrecisionTestOpen = !isPrecisionTestOpen;
isConfigPanelOpen = false;
isDevKitConfigOpen = false;
return;
}
if (event.detail === "settings") { if (event.detail === "settings") {
isPrecisionTestOpen = false; stageViewMode = "webgl";
isConfigPanelOpen = !isConfigPanelOpen; isConfigPanelOpen = !isConfigPanelOpen;
isDevKitConfigOpen = false; isDevKitConfigOpen = false;
return; return;
} }
if (event.detail === "devkit") { if (event.detail === "devkit") {
isPrecisionTestOpen = false;
isConfigPanelOpen = false; isConfigPanelOpen = false;
isDevKitConfigOpen = !isDevKitConfigOpen; isDevKitConfigOpen = !isDevKitConfigOpen;
return; return;
} }
isPrecisionTestOpen = false;
isConfigPanelOpen = false; isConfigPanelOpen = false;
isDevKitConfigOpen = false; isDevKitConfigOpen = false;
activeConfigLinkId = event.detail; activeConfigLinkId = event.detail;
@@ -1739,8 +1770,11 @@
} }
} }
function handleMatrixDisplayToggle(event: CustomEvent<boolean>): void { function handleStageModeChange(event: CustomEvent<StageViewMode>): void {
matrixDisplayMode = event.detail ? "dots" : "numeric"; stageViewMode = event.detail;
if (stageViewMode === "model3d") {
isConfigPanelOpen = false;
}
} }
onMount(() => { onMount(() => {
@@ -1770,12 +1804,25 @@
.catch((error) => { .catch((error) => {
console.error("Failed to listen for hud_stream:", error); console.error("Failed to listen for hud_stream:", error);
}); });
void listen<{ seq: number; timestampMs: number; dtsMs: number; angle: number }>( void listen<DevKitPztAngleEvent>("devkit_pzt_angle", (event) => {
"devkit_pzt_angle", const angleDeg = Number(event.payload.angle);
(event) => { if (!Number.isFinite(angleDeg)) {
console.log("[devkit_pzt_angle]", event.payload); clearDevkitSpatialForce();
return;
} }
)
devkitSpatialForce = {
angleDeg,
magnitude: 0,
confidence: 0
};
scheduleDevkitSpatialForceClear();
hasSignalData =
signalPanels.length > 0 ||
summary.points.length > 0 ||
spatialForce !== null ||
devkitSpatialForce !== null;
})
.then((unlisten) => { .then((unlisten) => {
if (disposed) { if (disposed) {
unlisten(); unlisten();
@@ -1794,6 +1841,7 @@
return () => { return () => {
disposed = true; disposed = true;
pauseReplayPlayback(); pauseReplayPlayback();
clearDevkitSpatialForce();
stopMockFeed?.(); stopMockFeed?.();
unlistenHudStream?.(); unlistenHudStream?.();
unlistenDevkitPztAngle?.(); unlistenDevkitPztAngle?.();
@@ -1835,9 +1883,8 @@
configLinksLabel={uiCopy.configLinksLabel} configLinksLabel={uiCopy.configLinksLabel}
refreshPortsLabel={uiCopy.refreshPortsLabel} refreshPortsLabel={uiCopy.refreshPortsLabel}
matrixViewLabel={uiCopy.matrixViewLabel} matrixViewLabel={uiCopy.matrixViewLabel}
matrixViewNumericLabel={uiCopy.matrixViewNumericLabel}
matrixViewDotsLabel={uiCopy.matrixViewDotsLabel} matrixViewDotsLabel={uiCopy.matrixViewDotsLabel}
{matrixDisplayMode} {stageViewMode}
connectActionLabel={uiCopy.connectActionLabel} connectActionLabel={uiCopy.connectActionLabel}
disconnectActionLabel={uiCopy.disconnectActionLabel} disconnectActionLabel={uiCopy.disconnectActionLabel}
exportActionLabel={uiCopy.exportActionLabel} exportActionLabel={uiCopy.exportActionLabel}
@@ -1859,7 +1906,7 @@
on:localechange={handleLocaleChange} on:localechange={handleLocaleChange}
on:portchange={handlePortChange} on:portchange={handlePortChange}
on:configlink={handleConfigLink} on:configlink={handleConfigLink}
on:matrixdisplaytoggle={handleMatrixDisplayToggle} on:stagemodechange={handleStageModeChange}
on:serialrefresh={handleSerialRefresh} on:serialrefresh={handleSerialRefresh}
on:serialconnect={handleSerialConnect} on:serialconnect={handleSerialConnect}
on:serialexport={handleSerialExportRequest} on:serialexport={handleSerialExportRequest}
@@ -1879,7 +1926,7 @@
bind:rangeMin bind:rangeMin
bind:rangeMax bind:rangeMax
bind:colorMapPreset bind:colorMapPreset
bind:matrixDisplayMode {stageViewMode}
configPanelTitle={uiCopy.configPanelTitle} configPanelTitle={uiCopy.configPanelTitle}
configPanelHint={uiCopy.configPanelHint} configPanelHint={uiCopy.configPanelHint}
matrixSizeLabel={uiCopy.matrixSizeLabel} matrixSizeLabel={uiCopy.matrixSizeLabel}
@@ -1906,8 +1953,9 @@
leftPanels={leftSignalPanels} leftPanels={leftSignalPanels}
rightPanels={rightSignalPanels} rightPanels={rightSignalPanels}
{pressureMatrix} {pressureMatrix}
{spatialForce}
{devkitSpatialForce}
showConfigPanel={isConfigPanelOpen} showConfigPanel={isConfigPanelOpen}
showPrecisionTestPanel={isPrecisionTestOpen}
{summary} {summary}
on:replaytoggle={handleReplayToggle} on:replaytoggle={handleReplayToggle}
on:replaystop={handleReplayStop} on:replaystop={handleReplayStop}
@@ -1916,7 +1964,7 @@
on:replayclose={handleReplayClose} on:replayclose={handleReplayClose}
on:configclose={() => (isConfigPanelOpen = false)} on:configclose={() => (isConfigPanelOpen = false)}
> >
{#if !isPrecisionTestOpen} {#if stageViewMode === "webgl"}
<section class="range-scale" aria-label="Signal Range"> <section class="range-scale" aria-label="Signal Range">
<p class="range-label">{locale === "zh-CN" ? "范围" : "Range"}</p> <p class="range-label">{locale === "zh-CN" ? "范围" : "Range"}</p>
<div class="range-track"> <div class="range-track">

14
static/models/README.md Normal file
View File

@@ -0,0 +1,14 @@
# 3D model assets
Put the first pipeline model here:
- Preferred: `static/models/je-skin-model.glb`
- Format: glTF 2.0 `.glb`
- Also supported after changing `modelUrl`: glTF 2.0 `.gltf` with its `.bin` and texture files in the same folder
- Not supported directly: older glTF 1.0 assets. Convert them to glTF 2.0 first.
Runtime URL used by the app:
```text
/models/je-skin-model.glb
```

Binary file not shown.