<template>
|
<div ref="host" class="holo-tooth" aria-label="全息牙齿">
|
<div class="holo-grid"></div>
|
<div class="holo-label">DIGITAL DENTAL · 3D ANALYSIS</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
import * as THREE from 'three'
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
|
const host = ref(null)
|
|
let renderer, scene, camera, controls, animationId
|
let tooth, toothPoints, scanRing, scanLine, particleField
|
let resizeObserver
|
|
// 生成一个无需下载模型的“牙齿”参数曲面。
|
// 后续如果有真实 tooth.glb,可直接替换 buildProceduralTooth()。
|
function buildProceduralTooth() {
|
const positions = []
|
const normals = []
|
const uvs = []
|
const indices = []
|
|
// v: 0 牙根底 -> 1 牙冠顶;a=0 为左右外侧(牙尖方向),a=±π/2 为正中(凹陷方向)
|
const rows = 60
|
const cols = 84
|
const FLAT = 0.62 // 前后压扁,呈现卡通牙比例
|
|
for (let iy = 0; iy <= rows; iy++) {
|
const v = iy / rows
|
|
let radius
|
let y
|
let dip = 0
|
let notch = 0
|
|
let rootTaper = 0
|
let tipDrop = 0
|
let tipPinch = 1
|
if (v < 0.42) {
|
// 牙根:粗短双根,根尖收细圆钝,中央深 U 形分叉
|
const t = v / 0.42
|
if (t < 0.45) tipPinch = 0.78 + 0.22 * Math.pow(t / 0.45, 1.3)
|
radius = 0.42 + 0.16 * Math.pow(t, 0.75)
|
y = -1.28 + t * 1.18
|
notch = 0.66 * Math.pow(1 - t, 1.4)
|
rootTaper = 1 - t
|
tipDrop = 0.12 * rootTaper
|
} else {
|
// 牙冠:圆顶鼓冠、咬合面平缓中央沟
|
const t = (v - 0.42) / 0.58
|
radius = 0.58 + 0.4 * Math.pow(Math.sin(t * Math.PI * 0.72), 1.25)
|
y = -0.1 + t * 1.48
|
if (t > 0.55) dip = 0.26 * Math.pow((t - 0.55) / 0.45, 1.15)
|
if (t > 0.78) radius *= Math.sqrt(Math.max(0, 1 - Math.pow(Math.min(1, (t - 0.78) / 0.22), 2)))
|
}
|
|
for (let ix = 0; ix <= cols; ix++) {
|
const u = ix / cols
|
const a = u * Math.PI * 2
|
|
const cosA = Math.cos(a)
|
const absC = Math.abs(cosA)
|
const center = Math.pow(1 - absC, 0.9) // 正中=1,两侧牙尖=0,缓指数让分叉更宽
|
|
// 根尖轻度外撇并收细,形成两个圆钝独立根
|
const rr = radius * tipPinch * (1 + 0.26 * rootTaper * rootTaper * absC)
|
|
// 咬合面:左右双牙尖、中央下凹;牙根:中央上抬形成分叉、根尖下探但指数<1 使轮廓圆钝
|
const yy = y - dip * center + notch * center - tipDrop * Math.pow(absC, 0.55)
|
|
// 根部整体向左右外弯,呈双根外撇的弧线
|
const x = cosA * rr + 0.14 * rootTaper * rootTaper * cosA
|
const z = Math.sin(a) * rr * FLAT
|
|
positions.push(x, yy, z)
|
normals.push(x, 0, z)
|
uvs.push(u, v)
|
}
|
}
|
|
for (let iy = 0; iy < rows; iy++) {
|
for (let ix = 0; ix < cols; ix++) {
|
const a = iy * (cols + 1) + ix
|
const b = a + 1
|
const c = a + cols + 1
|
const d = c + 1
|
indices.push(a, c, b, b, c, d)
|
}
|
}
|
|
const geometry = new THREE.BufferGeometry()
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
geometry.setIndex(indices)
|
geometry.computeVertexNormals()
|
|
// 主体:半透明青白色全息材质
|
const material = new THREE.MeshBasicMaterial({
|
color: 0x6fd4ff,
|
transparent: true,
|
opacity: 0.08,
|
side: THREE.DoubleSide,
|
depthWrite: false,
|
blending: THREE.AdditiveBlending
|
})
|
|
tooth = new THREE.Mesh(geometry, material)
|
|
// 网格线
|
const wire = new THREE.LineSegments(
|
new THREE.WireframeGeometry(geometry),
|
new THREE.LineBasicMaterial({
|
color: 0x4fdcff,
|
transparent: true,
|
opacity: 0.72,
|
blending: THREE.AdditiveBlending
|
})
|
)
|
tooth.add(wire)
|
|
// 点云
|
const pointGeo = geometry.clone()
|
const pointMat = new THREE.PointsMaterial({
|
color: 0x7fd8ff,
|
size: 0.02,
|
transparent: true,
|
opacity: 0.5,
|
blending: THREE.AdditiveBlending,
|
depthWrite: false
|
})
|
toothPoints = new THREE.Points(pointGeo, pointMat)
|
tooth.add(toothPoints)
|
|
return tooth
|
}
|
|
function buildParticles() {
|
const count = 1500
|
const arr = new Float32Array(count * 3)
|
|
for (let i = 0; i < count; i++) {
|
const r = 1.7 + Math.random() * 2.8
|
const a = Math.random() * Math.PI * 2
|
const y = (Math.random() - 0.5) * 4.2
|
|
arr[i * 3] = Math.cos(a) * r * (0.7 + Math.random() * 0.5)
|
arr[i * 3 + 1] = y
|
arr[i * 3 + 2] = Math.sin(a) * r * (0.45 + Math.random() * 0.5)
|
}
|
|
const geo = new THREE.BufferGeometry()
|
geo.setAttribute('position', new THREE.BufferAttribute(arr, 3))
|
|
const mat = new THREE.PointsMaterial({
|
color: 0x62ddff,
|
size: 0.018,
|
transparent: true,
|
opacity: 0.55,
|
blending: THREE.AdditiveBlending,
|
depthWrite: false
|
})
|
|
particleField = new THREE.Points(geo, mat)
|
scene.add(particleField)
|
}
|
|
function buildScanSystem() {
|
scanRing = new THREE.Mesh(
|
new THREE.TorusGeometry(1.0, 0.012, 8, 96),
|
new THREE.MeshBasicMaterial({
|
color: 0x55e7ff,
|
transparent: true,
|
opacity: 0.9,
|
blending: THREE.AdditiveBlending
|
})
|
)
|
scanRing.rotation.x = Math.PI / 2
|
scanRing.position.y = -1.52
|
scene.add(scanRing)
|
|
const lineGeo = new THREE.PlaneGeometry(2.2, 0.018)
|
const lineMat = new THREE.MeshBasicMaterial({
|
color: 0x7feeff,
|
transparent: true,
|
opacity: 0.8,
|
blending: THREE.AdditiveBlending,
|
side: THREE.DoubleSide
|
})
|
scanLine = new THREE.Mesh(lineGeo, lineMat)
|
scanLine.position.y = -1.55
|
scene.add(scanLine)
|
}
|
|
function init() {
|
scene = new THREE.Scene()
|
|
camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
|
camera.position.set(0, 0.1, 6.6)
|
|
renderer = new THREE.WebGLRenderer({
|
antialias: true,
|
alpha: true,
|
powerPreference: 'high-performance'
|
})
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
renderer.setClearColor(0x000000, 0)
|
renderer.outputColorSpace = THREE.SRGBColorSpace
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
renderer.toneMappingExposure = 1.15
|
host.value.appendChild(renderer.domElement)
|
|
// 柔和的立体光
|
scene.add(new THREE.AmbientLight(0x7bdfff, 2.0))
|
|
const key = new THREE.PointLight(0x55dfff, 7, 10)
|
key.position.set(2.2, 2.8, 3.8)
|
scene.add(key)
|
|
const rim = new THREE.PointLight(0x197cff, 5, 8)
|
rim.position.set(-3, 0, -2)
|
scene.add(rim)
|
|
tooth = buildProceduralTooth()
|
scene.add(tooth)
|
|
buildParticles()
|
buildScanSystem()
|
|
controls = new OrbitControls(camera, renderer.domElement)
|
controls.enableDamping = true
|
controls.enablePan = false
|
controls.enableZoom = false
|
controls.autoRotate = true
|
controls.autoRotateSpeed = 0.65
|
|
// 不使用 EffectComposer:bloom 后期通道会丢失画布 alpha,导致透明背景变灰白
|
resizeObserver = new ResizeObserver(resize)
|
resizeObserver.observe(host.value)
|
|
resize()
|
animate()
|
}
|
|
function resize() {
|
if (!host.value || !renderer) return
|
|
const { width, height } = host.value.getBoundingClientRect()
|
const w = Math.max(1, width)
|
const h = Math.max(1, height)
|
|
renderer.setSize(w, h, false)
|
|
camera.aspect = w / h
|
camera.updateProjectionMatrix()
|
}
|
|
function animate(time = 0) {
|
animationId = requestAnimationFrame(animate)
|
|
const t = time * 0.001
|
|
if (tooth) {
|
tooth.rotation.y = Math.sin(t * 0.35) * 0.22
|
tooth.position.y = Math.sin(t * 1.15) * 0.045
|
}
|
|
if (toothPoints) {
|
toothPoints.rotation.y = -t * 0.08
|
toothPoints.material.opacity = 0.42 + Math.sin(t * 2.8) * 0.16
|
}
|
|
if (particleField) {
|
particleField.rotation.y = t * 0.045
|
particleField.rotation.x = Math.sin(t * 0.2) * 0.08
|
}
|
|
if (scanRing) {
|
scanRing.position.y = -1.46 + ((t * 0.7) % 2.86)
|
scanRing.scale.setScalar(0.7 + Math.sin(t * 3) * 0.08)
|
}
|
|
if (scanLine) {
|
scanLine.position.y = -1.62 + ((t * 0.72) % 3.1)
|
scanLine.material.opacity = 0.2 + Math.abs(Math.sin(t * 5)) * 0.4
|
}
|
|
controls?.update()
|
renderer.render(scene, camera)
|
}
|
|
onMounted(init)
|
|
onBeforeUnmount(() => {
|
cancelAnimationFrame(animationId)
|
resizeObserver?.disconnect()
|
controls?.dispose()
|
renderer?.dispose()
|
|
if (host.value && renderer?.domElement?.parentNode) {
|
renderer.domElement.parentNode.removeChild(renderer.domElement)
|
}
|
})
|
</script>
|
|
<style scoped>
|
.holo-tooth {
|
position: relative;
|
width: 100%;
|
height: 100%;
|
min-height: 260px;
|
overflow: hidden;
|
pointer-events: auto;
|
}
|
|
.holo-tooth canvas {
|
position: absolute;
|
inset: 0;
|
width: 100%;
|
height: 100%;
|
filter: drop-shadow(0 0 6px rgba(85, 231, 255, 0.55));
|
}
|
|
.holo-grid {
|
position: absolute;
|
inset: 8% 10%;
|
border: 1px solid rgba(82, 211, 255, 0.16);
|
border-radius: 50%;
|
background:
|
linear-gradient(rgba(72, 214, 255, 0.07) 1px, transparent 1px),
|
linear-gradient(90deg, rgba(72, 214, 255, 0.07) 1px, transparent 1px);
|
background-size: 28px 28px;
|
mask-image: radial-gradient(ellipse, black 30%, transparent 72%);
|
pointer-events: none;
|
}
|
|
.holo-label {
|
position: absolute;
|
left: 50%;
|
bottom: 8%;
|
transform: translateX(-50%);
|
font: 500 9px/1.2 Inter, "Segoe UI", sans-serif;
|
letter-spacing: 0.24em;
|
color: rgba(57, 185, 235, 0.62);
|
white-space: nowrap;
|
pointer-events: none;
|
}
|
</style>
|