feat(ui): ModelStage.svelte 进一步功能扩展与重构
Some checks failed
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
lenn
2026-06-23 10:22:39 +08:00
parent f55bd20288
commit dd30e0e6af

View File

@@ -41,15 +41,79 @@
const FIXED_CAMERA_POSITION = new THREE.Vector3(-0.113, -0.149, 10.911); 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 FIXED_CAMERA_TARGET = new THREE.Vector3(0, 2.35, 0);
const MODEL_FRONT_ROTATION_Y = -Math.PI / 2; const MODEL_FRONT_ROTATION_Y = -Math.PI / 2;
const MODEL_UPRIGHT_ROTATION_Z = Math.PI * 1.5 - THREE.MathUtils.degToRad(2.2); const MODEL_UPRIGHT_ROTATION_Z =
Math.PI * 1.5 - THREE.MathUtils.degToRad(2.2);
const sensorRegions: SensorRegion[] = [ 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: "thumb",
{ id: "middle", rows: 12, cols: 7, x: 0.03, y: 4.18, width: 0.6, height: 1.14, rotation: 0 }, rows: 12,
{ id: "ring", rows: 12, cols: 7, x: 0.8, y: 4, width: 0.54, height: 1, rotation: THREE.MathUtils.degToRad(-5) }, cols: 7,
{ id: "pinky", rows: 12, cols: 7, x: 1.58, y: 3.36, width: 0.48, height: 0.92, rotation: THREE.MathUtils.degToRad(-8) }, x: -1.38,
{ id: "palm-horizontal", rows: 5, cols: 14, x: 0.18, y: 1.34, width: 1.84, height: 0.54, rotation: 0 }, y: 1.98,
{ id: "palm-vertical", rows: 11, cols: 4, x: 1.02, y: 0.2, width: 0.44, height: 1.46, rotation: 0 } width: 0.52,
height: 0.98,
rotation: THREE.MathUtils.degToRad(35),
},
{
id: "index",
rows: 12,
cols: 7,
x: -0.6,
y: 3.75,
width: 0.52,
height: 0.98,
rotation: THREE.MathUtils.degToRad(6),
},
{
id: "middle",
rows: 12,
cols: 7,
x: 0.1,
y: 4.0,
width: 0.58,
height: 1.06,
rotation: 0,
},
{
id: "ring",
rows: 12,
cols: 7,
x: 0.82,
y: 3.9,
width: 0.52,
height: 0.96,
rotation: THREE.MathUtils.degToRad(-5),
},
{
id: "pinky",
rows: 12,
cols: 7,
x: 1.42,
y: 3.2,
width: 0.46,
height: 0.88,
rotation: THREE.MathUtils.degToRad(-8),
},
{
id: "palm-horizontal",
rows: 5,
cols: 14,
x: 0.26,
y: 1.68,
width: 1.84,
height: 0.5,
rotation: THREE.MathUtils.degToRad(-4),
},
{
id: "palm-vertical",
rows: 11,
cols: 4,
x: 0.86,
y: 0.5,
width: 0.42,
height: 1.26,
rotation: THREE.MathUtils.degToRad(-4),
},
]; ];
$: canvasLabel = locale === "zh-CN" ? "3D 模型" : "3D Model"; $: canvasLabel = locale === "zh-CN" ? "3D 模型" : "3D Model";
@@ -89,14 +153,18 @@
color: 0x0c1824, color: 0x0c1824,
emissive: 0x07131f, emissive: 0x07131f,
metalness: 0.62, metalness: 0.62,
roughness: 0.34 roughness: 0.34,
}) }),
); );
platform.position.y = 0.18; platform.position.y = 0.18;
group.add(platform); group.add(platform);
const ringGeometry = new THREE.TorusGeometry(4.35, 0.035, 10, 128); const ringGeometry = new THREE.TorusGeometry(4.35, 0.035, 10, 128);
const ringMaterial = new THREE.MeshBasicMaterial({ color: cyan, transparent: true, opacity: 0.78 }); const ringMaterial = new THREE.MeshBasicMaterial({
color: cyan,
transparent: true,
opacity: 0.78,
});
for (let index = 0; index < 3; index += 1) { for (let index = 0; index < 3; index += 1) {
const ring = new THREE.Mesh(ringGeometry, ringMaterial); const ring = new THREE.Mesh(ringGeometry, ringMaterial);
ring.position.y = 0.52 + index * 0.52; ring.position.y = 0.52 + index * 0.52;
@@ -110,17 +178,31 @@
metalness: 0.48, metalness: 0.48,
roughness: 0.42, roughness: 0.42,
transparent: true, transparent: true,
opacity: 0.72 opacity: 0.72,
}); });
const core = new THREE.Mesh(new THREE.BoxGeometry(2.2, 3.4, 1.1), coreMaterial); const core = new THREE.Mesh(
new THREE.BoxGeometry(2.2, 3.4, 1.1),
coreMaterial,
);
core.position.y = 2.4; core.position.y = 2.4;
core.rotation.y = -0.36; core.rotation.y = -0.36;
group.add(core); group.add(core);
const sensorMaterial = new THREE.MeshBasicMaterial({ color: lime, transparent: true, opacity: 0.88 }); const sensorMaterial = new THREE.MeshBasicMaterial({
color: lime,
transparent: true,
opacity: 0.88,
});
for (let index = 0; index < 7; index += 1) { for (let index = 0; index < 7; index += 1) {
const bead = new THREE.Mesh(new THREE.SphereGeometry(0.13, 18, 18), sensorMaterial); const bead = new THREE.Mesh(
bead.position.set(-0.72 + index * 0.24, 3.18 + Math.sin(index * 0.72) * 0.18, 0.6); new THREE.SphereGeometry(0.13, 18, 18),
sensorMaterial,
);
bead.position.set(
-0.72 + index * 0.24,
3.18 + Math.sin(index * 0.72) * 0.18,
0.6,
);
group.add(bead); group.add(bead);
} }
@@ -131,7 +213,11 @@
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
function mixColors(low: THREE.Color, high: THREE.Color, amount: number): THREE.Color { function mixColors(
low: THREE.Color,
high: THREE.Color,
amount: number,
): THREE.Color {
return low.clone().lerp(high, clamp(amount, 0, 1)); return low.clone().lerp(high, clamp(amount, 0, 1));
} }
@@ -159,8 +245,14 @@
sensorPointCount = positions.length / 3; sensorPointCount = positions.length / 3;
sensorColors = new Float32Array(sensorPointCount * 3); sensorColors = new Float32Array(sensorPointCount * 3);
sensorGeometry = new THREE.BufferGeometry(); sensorGeometry = new THREE.BufferGeometry();
sensorGeometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); sensorGeometry.setAttribute(
sensorGeometry.setAttribute("color", new THREE.BufferAttribute(sensorColors, 3)); "position",
new THREE.Float32BufferAttribute(positions, 3),
);
sensorGeometry.setAttribute(
"color",
new THREE.BufferAttribute(sensorColors, 3),
);
const points = new THREE.Points( const points = new THREE.Points(
sensorGeometry, sensorGeometry,
@@ -171,8 +263,8 @@
opacity: 0.92, opacity: 0.92,
depthTest: false, depthTest: false,
depthWrite: false, depthWrite: false,
sizeAttenuation: true sizeAttenuation: true,
}) }),
); );
points.renderOrder = 20; points.renderOrder = 20;
points.name = "hand-sensor-dot-overlay"; points.name = "hand-sensor-dot-overlay";
@@ -185,7 +277,8 @@
return; return;
} }
const palette = pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald; const palette =
pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald;
const base = new THREE.Color(palette.labelZero); const base = new THREE.Color(palette.labelZero);
const mid = new THREE.Color(palette.surfaceHigh); const mid = new THREE.Color(palette.surfaceHigh);
const hot = new THREE.Color(palette.surfaceHot); const hot = new THREE.Color(palette.surfaceHot);
@@ -194,7 +287,9 @@
for (let index = 0; index < sensorPointCount; index += 1) { for (let index = 0; index < sensorPointCount; index += 1) {
const value = pressureMatrix?.[index]; const value = pressureMatrix?.[index];
const normalized = Number.isFinite(value) ? clamp((Number(value) - rangeMin) / span, 0, 1) : 0; const normalized = Number.isFinite(value)
? clamp((Number(value) - rangeMin) / span, 0, 1)
: 0;
const color = const color =
normalized <= 0.5 normalized <= 0.5
? mixColors(base, mid, normalized * 2) ? mixColors(base, mid, normalized * 2)
@@ -212,7 +307,8 @@
} }
function materialToUnlit(material: THREE.Material): THREE.Material { function materialToUnlit(material: THREE.Material): THREE.Material {
const source = material as THREE.MeshStandardMaterial & THREE.MeshPhysicalMaterial; const source = material as THREE.MeshStandardMaterial &
THREE.MeshPhysicalMaterial;
const unlit = new THREE.MeshBasicMaterial({ const unlit = new THREE.MeshBasicMaterial({
color: source.color?.clone() ?? new THREE.Color(0xffffff), color: source.color?.clone() ?? new THREE.Color(0xffffff),
map: source.map ?? null, map: source.map ?? null,
@@ -223,7 +319,7 @@
depthWrite: source.depthWrite, depthWrite: source.depthWrite,
depthTest: source.depthTest, depthTest: source.depthTest,
vertexColors: source.vertexColors, vertexColors: source.vertexColors,
toneMapped: false toneMapped: false,
}); });
return unlit; return unlit;
@@ -250,7 +346,11 @@
let bounds = new THREE.Box3().setFromObject(object); let bounds = new THREE.Box3().setFromObject(object);
const size = bounds.getSize(new THREE.Vector3()); const size = bounds.getSize(new THREE.Vector3());
const currentHeight = Math.max(size.y, 0.001); const currentHeight = Math.max(size.y, 0.001);
const scale = clamp(MODEL_TARGET_HEIGHT / currentHeight, MODEL_MIN_SCALE, MODEL_MAX_SCALE); const scale = clamp(
MODEL_TARGET_HEIGHT / currentHeight,
MODEL_MIN_SCALE,
MODEL_MAX_SCALE,
);
object.scale.multiplyScalar(scale); object.scale.multiplyScalar(scale);
object.updateMatrixWorld(true); object.updateMatrixWorld(true);
@@ -265,7 +365,11 @@
return new THREE.Box3().setFromObject(object); return new THREE.Box3().setFromObject(object);
} }
function frameObject(object: THREE.Object3D, camera: THREE.PerspectiveCamera, controls: OrbitControls): void { function frameObject(
object: THREE.Object3D,
camera: THREE.PerspectiveCamera,
controls: OrbitControls,
): void {
normalizeObjectToStage(object); normalizeObjectToStage(object);
camera.position.copy(FIXED_CAMERA_POSITION); camera.position.copy(FIXED_CAMERA_POSITION);
camera.near = 0.05; camera.near = 0.05;
@@ -286,7 +390,7 @@
canvas: canvasEl, canvas: canvasEl,
antialias: true, antialias: true,
alpha: true, alpha: true,
powerPreference: "high-performance" powerPreference: "high-performance",
}); });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x03070d, 1); renderer.setClearColor(0x03070d, 1);
@@ -347,8 +451,8 @@
metalness: 0.28, metalness: 0.28,
roughness: 0.64, roughness: 0.64,
transparent: true, transparent: true,
opacity: 0.72 opacity: 0.72,
}) }),
); );
floor.rotation.x = -Math.PI / 2; floor.rotation.x = -Math.PI / 2;
floor.position.y = FLOOR_Y - 0.018; floor.position.y = FLOOR_Y - 0.018;
@@ -386,7 +490,7 @@
loadError = message || "Unknown model loader error"; loadError = message || "Unknown model loader error";
loadState = message.toLowerCase().includes("404") ? "missing" : "error"; loadState = message.toLowerCase().includes("404") ? "missing" : "error";
loadProgress = 0; loadProgress = 0;
} },
); );
const resize = () => { const resize = () => {
@@ -432,7 +536,8 @@
</script> </script>
<div class="model-stage" bind:this={rootEl}> <div class="model-stage" bind:this={rootEl}>
<canvas class="model-canvas" bind:this={canvasEl} aria-label={canvasLabel}></canvas> <canvas class="model-canvas" bind:this={canvasEl} aria-label={canvasLabel}
></canvas>
<div class="model-vignette" aria-hidden="true"></div> <div class="model-vignette" aria-hidden="true"></div>
<div class="model-scanlines" aria-hidden="true"></div> <div class="model-scanlines" aria-hidden="true"></div>
</div> </div>
@@ -442,9 +547,16 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
background: background: radial-gradient(
radial-gradient(circle at 52% 62%, rgb(94 231 255 / 0.12), transparent 26%), circle at 52% 62%,
radial-gradient(circle at 24% 18%, rgb(166 255 122 / 0.07), transparent 24%), rgb(94 231 255 / 0.12),
transparent 26%
),
radial-gradient(
circle at 24% 18%,
rgb(166 255 122 / 0.07),
transparent 24%
),
linear-gradient(180deg, #03070d 0%, #07111b 48%, #02050a 100%); linear-gradient(180deg, #03070d 0%, #07111b 48%, #02050a 100%);
} }
@@ -467,16 +579,25 @@
} }
.model-vignette { .model-vignette {
background: background: linear-gradient(
linear-gradient(90deg, rgb(0 0 0 / 0.36), transparent 22%, transparent 78%, rgb(0 0 0 / 0.34)), 90deg,
rgb(0 0 0 / 0.36),
transparent 22%,
transparent 78%,
rgb(0 0 0 / 0.34)
),
radial-gradient(circle at center, transparent 48%, rgb(0 0 0 / 0.58) 100%); radial-gradient(circle at center, transparent 48%, rgb(0 0 0 / 0.58) 100%);
} }
.model-scanlines { .model-scanlines {
opacity: 0.32; opacity: 0.32;
background: background: repeating-linear-gradient(
repeating-linear-gradient(180deg, rgb(94 231 255 / 0.045) 0, rgb(94 231 255 / 0.045) 1px, transparent 1px, transparent 4px); 180deg,
rgb(94 231 255 / 0.045) 0,
rgb(94 231 255 / 0.045) 1px,
transparent 1px,
transparent 4px
);
mix-blend-mode: screen; mix-blend-mode: screen;
} }
</style> </style>