Files
momentry_portal/src/components/Face3DViewer.vue
2026-05-20 08:29:37 +08:00

175 lines
5.0 KiB
Vue

<template>
<div ref="container" class="w-full h-full min-h-[300px] bg-gray-900 rounded-lg overflow-hidden"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const props = defineProps<{
landmarks: number[][] // 468 x [x, y, z]
}>()
const container = ref<HTMLElement>()
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let animId: number
let objects: THREE.Object3D[] = []
function disposeScene() {
cancelAnimationFrame(animId)
for (const obj of objects) {
scene?.remove(obj)
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose()
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose())
else obj.material?.dispose()
}
if (obj instanceof THREE.Points) {
obj.geometry?.dispose()
if (obj.material) obj.material.dispose()
}
}
objects = []
controls?.dispose()
controls = null
if (renderer) {
renderer.dispose()
renderer = null
}
scene = null
camera = null
}
const FACES_TRI = [
// Eyes
[33, 133, 7], [33, 7, 163], [160, 159, 158], [159, 158, 157],
// Nose
[168, 6, 197], [6, 197, 195], [197, 195, 5],
// Mouth outer
[61, 146, 91], [91, 181, 84], [84, 17, 314], [314, 405, 321],
// Mouth inner
[78, 95, 88], [95, 88, 178], [87, 14, 317], [14, 317, 402],
// Jaw
[10, 338, 297], [297, 332, 284], [284, 251, 389],
// Left eye brow
[46, 53, 52], [53, 52, 65],
// Right eye brow
[276, 283, 282], [283, 282, 295],
// Face oval
[10, 338, 297], [297, 332, 284], [284, 251, 389], [389, 356, 454],
[454, 323, 361], [361, 288, 397], [397, 365, 379], [379, 378, 400],
[400, 377, 152], [152, 148, 176], [176, 149, 150], [150, 136, 172],
[172, 58, 132], [132, 93, 234], [234, 127, 162], [162, 21, 54],
[54, 103, 67], [67, 109, 10]
]
function buildMesh(pts: number[][]): THREE.BufferGeometry {
const verts = new Float32Array(pts.length * 3)
for (let i = 0; i < pts.length; i++) {
verts[i * 3] = (pts[i][0] - 0.5) * 2
verts[i * 3 + 1] = -(pts[i][1] - 0.5) * 2
verts[i * 3 + 2] = pts[i][2] * 2
}
const indices: number[] = []
for (const tri of FACES_TRI) {
if (tri.every(i => i < pts.length)) indices.push(...tri)
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3))
geo.setIndex(indices)
geo.computeVertexNormals()
return geo
}
function init() {
if (!container.value) return
// Dispose previous scene if re-initializing
disposeScene()
const rect = container.value.getBoundingClientRect()
const w = rect.width || 400, h = rect.height || 300
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10)
camera.position.set(0, 0, 2.5)
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(w, h)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
container.value.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.autoRotate = true
controls.autoRotateSpeed = 2
// Mesh
if (props.landmarks?.length) {
const geo = buildMesh(props.landmarks)
const mat = new THREE.MeshPhongMaterial({
color: 0x4488ff,
flatShading: false,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide
})
const mesh = new THREE.Mesh(geo, mat)
scene.add(mesh)
objects.push(mesh)
// Points
const ptGeo = new THREE.BufferGeometry()
ptGeo.setAttribute('position', geo.getAttribute('position')!)
const ptMat = new THREE.PointsMaterial({ color: 0x88bbff, size: 0.008 })
const points = new THREE.Points(ptGeo, ptMat)
scene.add(points)
objects.push(points)
}
// Lights
const ambient = new THREE.AmbientLight(0x404060)
scene.add(ambient)
const dir = new THREE.DirectionalLight(0xffffff, 1)
dir.position.set(1, 1, 1)
scene.add(dir)
const dir2 = new THREE.DirectionalLight(0x8888ff, 0.5)
dir2.position.set(-1, -1, 0.5)
scene.add(dir2)
// Resize observer
const resizeObserver = new ResizeObserver(() => {
if (!container.value || !renderer || !camera) return
const r = container.value.getBoundingClientRect()
const w = r.width || 400, h = r.height || 300
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
})
resizeObserver.observe(container.value)
;(container.value as any).__resizeObserver = resizeObserver
animate()
}
function animate() {
animId = requestAnimationFrame(animate)
controls?.update()
if (renderer && scene && camera) renderer.render(scene, camera)
}
onMounted(() => init())
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
if ((container.value as any)?.__resizeObserver) {
(container.value as any).__resizeObserver.disconnect()
}
disposeScene()
})
</script>