feat: 新增 Playwright 测试 + ModelStage UI 更新与 serial 小修
Some checks failed
Playwright Tests / test (push) Has been cancelled
Some checks failed
Playwright Tests / test (push) Has been cancelled
This commit is contained in:
@@ -188,7 +188,14 @@
|
||||
{#if isModelStage}
|
||||
<div class="canvas-wrap">
|
||||
{#key modelUrl}
|
||||
<ModelStage {locale} {modelUrl} />
|
||||
<ModelStage
|
||||
{locale}
|
||||
{modelUrl}
|
||||
{pressureMatrix}
|
||||
{rangeMin}
|
||||
{rangeMax}
|
||||
{colorMapPreset}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{:else if showPrecisionTestPanel}
|
||||
|
||||
@@ -4,12 +4,27 @@
|
||||
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";
|
||||
import { pressureColorPalettes } from "$lib/config/color-map";
|
||||
import type { LocaleCode, PressureColorMapPreset } from "$lib/types/hud";
|
||||
|
||||
type ModelLoadState = "loading" | "ready" | "missing" | "error";
|
||||
type SensorRegion = {
|
||||
id: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
|
||||
export let locale: LocaleCode = "zh-CN";
|
||||
export let modelUrl = "/models/je-skin-model.glb";
|
||||
export let pressureMatrix: number[] | null = null;
|
||||
export let rangeMin = 0;
|
||||
export let rangeMax = 4095;
|
||||
export let colorMapPreset: PressureColorMapPreset = "emerald";
|
||||
|
||||
let rootEl: HTMLDivElement | undefined;
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
@@ -22,13 +37,29 @@
|
||||
const MODEL_TARGET_HEIGHT = 6.55;
|
||||
const MODEL_MIN_SCALE = 0.02;
|
||||
const MODEL_MAX_SCALE = 80;
|
||||
const SENSOR_Z = 0.91;
|
||||
const FIXED_CAMERA_POSITION = new THREE.Vector3(-0.113, -0.149, 10.911);
|
||||
const FIXED_CAMERA_TARGET = new THREE.Vector3(0, 2.35, 0);
|
||||
const MODEL_FRONT_ROTATION_Y = -Math.PI / 2;
|
||||
const MODEL_UPRIGHT_ROTATION_Z = Math.PI * 1.5 - THREE.MathUtils.degToRad(2.2);
|
||||
const sensorRegions: SensorRegion[] = [
|
||||
{ id: "thumb", rows: 12, cols: 7, x: -1.48, y: 1.95, width: 0.54, height: 1.02, rotation: THREE.MathUtils.degToRad(35) },
|
||||
{ id: "index", rows: 12, cols: 7, x: -0.72, y: 3.84, width: 0.54, height: 1.02, rotation: THREE.MathUtils.degToRad(6) },
|
||||
{ id: "middle", rows: 12, cols: 7, x: 0.03, y: 4.18, width: 0.6, height: 1.14, rotation: 0 },
|
||||
{ id: "ring", rows: 12, cols: 7, x: 0.8, y: 4, width: 0.54, height: 1, rotation: THREE.MathUtils.degToRad(-5) },
|
||||
{ id: "pinky", rows: 12, cols: 7, x: 1.58, y: 3.36, width: 0.48, height: 0.92, rotation: THREE.MathUtils.degToRad(-8) },
|
||||
{ id: "palm-horizontal", rows: 5, cols: 14, x: 0.18, y: 1.34, width: 1.84, height: 0.54, rotation: 0 },
|
||||
{ id: "palm-vertical", rows: 11, cols: 4, x: 1.02, y: 0.2, width: 0.44, height: 1.46, rotation: 0 }
|
||||
];
|
||||
|
||||
$: canvasLabel = locale === "zh-CN" ? "3D 模型" : "3D Model";
|
||||
|
||||
$: updateSensorColors();
|
||||
|
||||
let sensorGeometry: THREE.BufferGeometry | null = null;
|
||||
let sensorColors: Float32Array | null = null;
|
||||
let sensorPointCount = 0;
|
||||
|
||||
function disposeObject3D(object: THREE.Object3D): void {
|
||||
object.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
@@ -100,6 +131,86 @@
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function mixColors(low: THREE.Color, high: THREE.Color, amount: number): THREE.Color {
|
||||
return low.clone().lerp(high, clamp(amount, 0, 1));
|
||||
}
|
||||
|
||||
function buildSensorOverlay(): THREE.Points {
|
||||
const positions: number[] = [];
|
||||
|
||||
for (const region of sensorRegions) {
|
||||
const sin = Math.sin(region.rotation);
|
||||
const cos = Math.cos(region.rotation);
|
||||
const colSpan = Math.max(region.cols - 1, 1);
|
||||
const rowSpan = Math.max(region.rows - 1, 1);
|
||||
|
||||
for (let row = 0; row < region.rows; row += 1) {
|
||||
for (let col = 0; col < region.cols; col += 1) {
|
||||
const localX = (col / colSpan - 0.5) * region.width;
|
||||
const localY = (0.5 - row / rowSpan) * region.height;
|
||||
const x = region.x + localX * cos - localY * sin;
|
||||
const y = region.y + localX * sin + localY * cos;
|
||||
|
||||
positions.push(x, y, SENSOR_Z);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sensorPointCount = positions.length / 3;
|
||||
sensorColors = new Float32Array(sensorPointCount * 3);
|
||||
sensorGeometry = new THREE.BufferGeometry();
|
||||
sensorGeometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
sensorGeometry.setAttribute("color", new THREE.BufferAttribute(sensorColors, 3));
|
||||
|
||||
const points = new THREE.Points(
|
||||
sensorGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
size: 0.052,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
);
|
||||
points.renderOrder = 20;
|
||||
points.name = "hand-sensor-dot-overlay";
|
||||
updateSensorColors();
|
||||
return points;
|
||||
}
|
||||
|
||||
function updateSensorColors(): void {
|
||||
if (!sensorGeometry || !sensorColors || sensorPointCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const palette = pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald;
|
||||
const base = new THREE.Color(palette.labelZero);
|
||||
const mid = new THREE.Color(palette.surfaceHigh);
|
||||
const hot = new THREE.Color(palette.surfaceHot);
|
||||
const span = Math.max(rangeMax - rangeMin, 1);
|
||||
const dimFactor = pressureMatrix ? 0.9 : 0.42;
|
||||
|
||||
for (let index = 0; index < sensorPointCount; index += 1) {
|
||||
const value = pressureMatrix?.[index];
|
||||
const normalized = Number.isFinite(value) ? clamp((Number(value) - rangeMin) / span, 0, 1) : 0;
|
||||
const color =
|
||||
normalized <= 0.5
|
||||
? mixColors(base, mid, normalized * 2)
|
||||
: mixColors(mid, hot, (normalized - 0.5) * 2);
|
||||
|
||||
sensorColors[index * 3] = color.r * dimFactor;
|
||||
sensorColors[index * 3 + 1] = color.g * dimFactor;
|
||||
sensorColors[index * 3 + 2] = color.b * dimFactor;
|
||||
}
|
||||
|
||||
const colorAttribute = sensorGeometry.getAttribute("color");
|
||||
if (colorAttribute) {
|
||||
colorAttribute.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function materialToUnlit(material: THREE.Material): THREE.Material {
|
||||
const source = material as THREE.MeshStandardMaterial & THREE.MeshPhysicalMaterial;
|
||||
const unlit = new THREE.MeshBasicMaterial({
|
||||
@@ -247,6 +358,8 @@
|
||||
applyUnlitMaterials(activeModel);
|
||||
scene.add(activeModel);
|
||||
frameObject(activeModel, camera, controls);
|
||||
const sensorOverlay = buildSensorOverlay();
|
||||
scene.add(sensorOverlay);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(
|
||||
@@ -311,6 +424,7 @@
|
||||
renderer.setAnimationLoop(null);
|
||||
controls.dispose();
|
||||
disposeObject3D(activeModel);
|
||||
disposeObject3D(sensorOverlay);
|
||||
disposeObject3D(labGroup);
|
||||
renderer.dispose();
|
||||
};
|
||||
|
||||
@@ -1849,6 +1849,7 @@
|
||||
let disposed = false;
|
||||
let unlistenHudStream: UnlistenFn | null = null;
|
||||
let unlistenHandGatewayRaw: UnlistenFn | null = null;
|
||||
let unlistenHandGatewayPressureMatrix: UnlistenFn | null = null;
|
||||
let unlistenDevkitPztAngle: UnlistenFn | null = null;
|
||||
let stopMockFeed: (() => void) | null = null;
|
||||
|
||||
@@ -1890,6 +1891,25 @@
|
||||
.catch((error) => {
|
||||
console.error("Failed to listen for hand_gateway_raw:", error);
|
||||
});
|
||||
void listen<number[]>("hand_gateway_pressure_matrix", (event) => {
|
||||
if (replayHasData) {
|
||||
return;
|
||||
}
|
||||
|
||||
pressureMatrix = event.payload;
|
||||
hasSignalData = event.payload.length > 0;
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
|
||||
unlistenHandGatewayPressureMatrix = unlisten;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to listen for hand_gateway_pressure_matrix:", error);
|
||||
});
|
||||
void listen<DevKitPztAngleEvent>("devkit_pzt_angle", (event) => {
|
||||
const angleDeg = Number(event.payload.angle);
|
||||
if (!Number.isFinite(angleDeg)) {
|
||||
@@ -1931,6 +1951,7 @@
|
||||
stopMockFeed?.();
|
||||
unlistenHudStream?.();
|
||||
unlistenHandGatewayRaw?.();
|
||||
unlistenHandGatewayPressureMatrix?.();
|
||||
unlistenDevkitPztAngle?.();
|
||||
if (devkitStatusTimer != null) {
|
||||
window.clearInterval(devkitStatusTimer);
|
||||
|
||||
Reference in New Issue
Block a user