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
This commit is contained in:
lenn
2026-05-18 23:32:58 +08:00
parent 83832139a8
commit 59e9203363
28 changed files with 1813 additions and 10 deletions

6
.gitignore vendored
View File

@@ -25,6 +25,12 @@ vite.config.ts.timestamp-*
/src-tauri/target/
/src-tauri/target-codex-check*/
/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/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

1
eskin-finger-sdk Submodule

Submodule eskin-finger-sdk added at 705375085f

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

@@ -6,6 +6,7 @@
import { fly } from "svelte/transition";
import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range";
import ConfigPanel from "$lib/components/ConfigPanel.svelte";
import ModelStage from "$lib/components/ModelStage.svelte";
import NeonBreakoutArena from "$lib/components/NeonBreakoutArena.svelte";
import PressureMatrixViewer from "$lib/components/PressureMatrixViewer.svelte";
import SignalChart from "$lib/components/SignalChart.svelte";
@@ -16,7 +17,8 @@
HudSummary,
LocaleCode,
MatrixDisplayMode,
PressureColorMapPreset
PressureColorMapPreset,
StageViewMode
} from "$lib/types/hud";
export let locale: LocaleCode = "zh-CN";
@@ -41,6 +43,8 @@
export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
export let colorMapPreset: PressureColorMapPreset = "emerald";
export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let stageViewMode: StageViewMode = "webgl";
export let modelUrl = "/models/je-skin-model.glb";
export let replaySectionLabel = "";
export let replayPlayLabel = "";
export let replayPauseLabel = "";
@@ -84,6 +88,7 @@
$: summaryCurveVisible = summary.points.length > 0 && summary.points.some((value) => Number.isFinite(value) && Math.abs(value) >= 0.0001);
$: splitMatrixTitle = locale === "zh-CN" ? "数字矩阵" : "Matrix";
$: splitMatrixHint = locale === "zh-CN" ? "实时压力数据 / 数字矩阵" : "Live pressure matrix";
$: isModelStage = stageViewMode === "model3d";
function toPxNumber(rawValue: string): number {
const value = Number.parseFloat(rawValue);
@@ -176,7 +181,13 @@
bind:this={stagePlaneEl}
style="--panel-zone-top-dyn: {panelZoneTopPx}px; --rail-scale-left: {leftRailScale}; --rail-scale-right: {rightRailScale};"
>
{#if showPrecisionTestPanel}
{#if isModelStage}
<div class="canvas-wrap">
{#key modelUrl}
<ModelStage {locale} {modelUrl} />
{/key}
</div>
{:else if showPrecisionTestPanel}
<div class="split-game-wrap">
<section class="split-panel split-matrix-panel">
<header class="split-panel-head">
@@ -232,7 +243,7 @@
</div>
{/if}
{#if showConfigPanel && !showPrecisionTestPanel}
{#if showConfigPanel && !showPrecisionTestPanel && !isModelStage}
<div class="config-panel-wrap">
<ConfigPanel
bind:matrixRows
@@ -254,7 +265,7 @@
</div>
{/if}
{#if !showPrecisionTestPanel}
{#if !showPrecisionTestPanel && !isModelStage}
<div class="panel-zone" bind:this={panelZoneEl}>
<aside class="side-rail left-rail">
<div class="rail-stack" bind:this={leftStackEl}>
@@ -326,7 +337,7 @@
</div>
{/if}
{#if replayHasData && !showPrecisionTestPanel}
{#if replayHasData && !showPrecisionTestPanel && !isModelStage}
<aside class="replay-floating-panel" class:is-left={replaySide === "left"} class:is-right={replaySide === "right"}>
<div class="replay-panel-head">
<div class="replay-panel-title-group">
@@ -364,7 +375,7 @@
</aside>
{/if}
{#if !showPrecisionTestPanel}
{#if !showPrecisionTestPanel && !isModelStage}
<div class="stage-bottom-overlay">
<slot />
</div>

View File

@@ -6,6 +6,7 @@
HudNoticeTone,
LocaleCode,
MatrixDisplayMode,
StageViewMode,
WindowControlAction
} from "$lib/types/hud";
@@ -34,6 +35,10 @@
export let matrixViewNumericLabel = "";
export let matrixViewDotsLabel = "";
export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let stageModeLabel = "";
export let stageModeWebglLabel = "";
export let stageModeModelLabel = "";
export let stageViewMode: StageViewMode = "webgl";
export let connectActionLabel = "";
export let disconnectActionLabel = "";
export let exportActionLabel = "";
@@ -56,6 +61,7 @@
localechange: LocaleCode;
configlink: string;
matrixdisplaytoggle: boolean;
stagemodechange: StageViewMode;
portchange: string;
serialrefresh: void;
serialconnect: string;
@@ -105,6 +111,10 @@
dispatch("matrixdisplaytoggle", matrixDisplayMode !== "dots");
}
function emitStageModeChange(nextMode: StageViewMode): void {
dispatch("stagemodechange", nextMode);
}
function emitPortChange(event: Event): void {
const target = event.currentTarget as HTMLSelectElement;
dispatch("portchange", target.value);
@@ -217,6 +227,28 @@
</button>
</section>
<section class="stage-mode-switch" aria-label={stageModeLabel}>
<span class="stage-mode-label">{stageModeLabel}</span>
<div class="stage-mode-options" role="group" aria-label={stageModeLabel}>
<button
type="button"
class="stage-mode-btn"
class:is-active={stageViewMode === "webgl"}
on:click={() => emitStageModeChange("webgl")}
>
{stageModeWebglLabel}
</button>
<button
type="button"
class="stage-mode-btn"
class:is-active={stageViewMode === "model3d"}
on:click={() => emitStageModeChange("model3d")}
>
{stageModeModelLabel}
</button>
</div>
</section>
<section class="state-card" aria-label={connectionLabel}>
<span class="state-dot" class:ok={connectionTone === "ok"} class:warn={connectionTone === "warn"}></span>
<span class="state-label">{connectionLabel}</span>
@@ -485,7 +517,8 @@
background: var(--panel-surface);
}
.matrix-switch-wrap {
.matrix-switch-wrap,
.stage-mode-switch {
display: inline-flex;
align-items: center;
gap: 0.4rem;
@@ -496,7 +529,8 @@
background: var(--panel-surface);
}
.matrix-switch-label {
.matrix-switch-label,
.stage-mode-label {
color: var(--panel-text-dim);
font-size: 0.66rem;
letter-spacing: 0.08em;
@@ -587,6 +621,45 @@
line-height: 1;
}
.stage-mode-options {
display: inline-flex;
align-items: center;
gap: 0.18rem;
padding: 0.16rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.24);
border-radius: 999px;
background: rgb(var(--hud-surface-deep-rgb) / 0.8);
}
.stage-mode-btn {
min-block-size: 1.38rem;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.18rem 0.54rem;
background: transparent;
color: rgb(var(--hud-text-dim-rgb) / 0.88);
font: inherit;
font-size: 0.7rem;
letter-spacing: 0.04em;
cursor: pointer;
transition:
border-color 180ms ease,
background-color 180ms ease,
color 180ms ease,
box-shadow 180ms ease;
}
.stage-mode-btn:hover {
color: rgb(var(--hud-text-main-rgb) / 0.96);
}
.stage-mode-btn.is-active {
border-color: rgb(var(--hud-cyan-rgb) / 0.42);
background: rgb(var(--hud-cyan-rgb) / 0.14);
color: rgb(var(--hud-text-main-rgb) / 0.98);
box-shadow: 0 0 12px rgb(var(--hud-cyan-rgb) / 0.1);
}
.state-dot {
inline-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

@@ -3,6 +3,7 @@ export type LocaleCode = "zh-CN" | "en-US";
export type WindowControlAction = "minimize" | "toggle-maximize" | "close";
export type ConnectionState = "online" | "connecting" | "offline";
export type StageViewMode = "webgl" | "model3d";
export type StageStatusTone = "ok" | "warn" | "idle";
export type HudNoticeTone = "ok" | "warn" | "info";
@@ -86,6 +87,9 @@ export interface HudCopy {
matrixViewLabel: string;
matrixViewNumericLabel: string;
matrixViewDotsLabel: string;
stageModeLabel: string;
stageModeWebglLabel: string;
stageModeModelLabel: string;
resetConfigLabel: string;
applyLiveHint: string;
runtimeReady: string;

View File

@@ -33,6 +33,7 @@
SerialRecordStateResult,
SerialImportResult,
SignalTone,
StageViewMode,
WindowControlAction
} from "$lib/types/hud";
@@ -62,6 +63,9 @@
matrixViewLabel: "矩阵模式",
matrixViewNumericLabel: "数字矩阵",
matrixViewDotsLabel: "点矩阵",
stageModeLabel: "渲染模式",
stageModeWebglLabel: "WebGL",
stageModeModelLabel: "3D 模型",
resetConfigLabel: "恢复默认",
applyLiveHint: "实时生效 / 矩阵尺寸变更将重建 viewer",
runtimeReady: "WEBGL2 READY",
@@ -121,6 +125,9 @@
matrixViewLabel: "Matrix Mode",
matrixViewNumericLabel: "Numeric",
matrixViewDotsLabel: "Dots",
stageModeLabel: "Render Mode",
stageModeWebglLabel: "WebGL",
stageModeModelLabel: "3D Model",
resetConfigLabel: "Reset",
applyLiveHint: "Live apply / size changes recreate the viewer",
runtimeReady: "WEBGL2 READY",
@@ -227,6 +234,7 @@
let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
let colorMapPreset: PressureColorMapPreset = "emerald";
let matrixDisplayMode: MatrixDisplayMode = "dots";
let stageViewMode: StageViewMode = "webgl";
let replayFrames: ReplayFrame[] = [];
let replayCurrentIndex = 0;
let replayHasDisplayedFrame = false;
@@ -1644,6 +1652,7 @@
function handleConfigLink(event: CustomEvent<string>): void {
if (event.detail === "precision-test") {
stageViewMode = "webgl";
isPrecisionTestOpen = !isPrecisionTestOpen;
isConfigPanelOpen = false;
isDevKitConfigOpen = false;
@@ -1651,6 +1660,7 @@
}
if (event.detail === "settings") {
stageViewMode = "webgl";
isPrecisionTestOpen = false;
isConfigPanelOpen = !isConfigPanelOpen;
isDevKitConfigOpen = false;
@@ -1743,6 +1753,14 @@
matrixDisplayMode = event.detail ? "dots" : "numeric";
}
function handleStageModeChange(event: CustomEvent<StageViewMode>): void {
stageViewMode = event.detail;
if (stageViewMode === "model3d") {
isPrecisionTestOpen = false;
isConfigPanelOpen = false;
}
}
onMount(() => {
let disposed = false;
let unlistenHudStream: UnlistenFn | null = null;
@@ -1838,6 +1856,10 @@
matrixViewNumericLabel={uiCopy.matrixViewNumericLabel}
matrixViewDotsLabel={uiCopy.matrixViewDotsLabel}
{matrixDisplayMode}
stageModeLabel={uiCopy.stageModeLabel}
stageModeWebglLabel={uiCopy.stageModeWebglLabel}
stageModeModelLabel={uiCopy.stageModeModelLabel}
{stageViewMode}
connectActionLabel={uiCopy.connectActionLabel}
disconnectActionLabel={uiCopy.disconnectActionLabel}
exportActionLabel={uiCopy.exportActionLabel}
@@ -1860,6 +1882,7 @@
on:portchange={handlePortChange}
on:configlink={handleConfigLink}
on:matrixdisplaytoggle={handleMatrixDisplayToggle}
on:stagemodechange={handleStageModeChange}
on:serialrefresh={handleSerialRefresh}
on:serialconnect={handleSerialConnect}
on:serialexport={handleSerialExportRequest}
@@ -1880,6 +1903,7 @@
bind:rangeMax
bind:colorMapPreset
bind:matrixDisplayMode
{stageViewMode}
configPanelTitle={uiCopy.configPanelTitle}
configPanelHint={uiCopy.configPanelHint}
matrixSizeLabel={uiCopy.matrixSizeLabel}
@@ -1916,7 +1940,7 @@
on:replayclose={handleReplayClose}
on:configclose={() => (isConfigPanelOpen = false)}
>
{#if !isPrecisionTestOpen}
{#if !isPrecisionTestOpen && stageViewMode === "webgl"}
<section class="range-scale" aria-label="Signal Range">
<p class="range-label">{locale === "zh-CN" ? "范围" : "Range"}</p>
<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.