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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -1216,4 +1289,4 @@
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
469
src/lib/components/ModelStage.svelte
Normal file
469
src/lib/components/ModelStage.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user