309 lines
8.2 KiB
Vue
309 lines
8.2 KiB
Vue
<template>
|
||
<div class="cad-model-viewer">
|
||
<div ref="containerRef" class="cad-model-viewer__canvas" />
|
||
<div v-if="loading" class="cad-model-viewer__overlay">
|
||
<n-spin size="large" />
|
||
<span>模型加载中…</span>
|
||
</div>
|
||
<div v-else-if="error" class="cad-model-viewer__overlay cad-model-viewer__overlay--error">
|
||
<n-empty :description="error" />
|
||
</div>
|
||
<div v-if="!loading && !error" class="cad-model-viewer__toolbar">
|
||
<n-button size="tiny" quaternary @click="resetView">复位</n-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||
import { NButton, NSpin, NEmpty } from 'naive-ui'
|
||
import * as THREE from 'three'
|
||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||
import { createGLTFLoader, disposeDracoLoader } from '@/utils/gltfLoader'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
const props = defineProps<{
|
||
/** 远程 glb/gltf 地址(需登录时可带 Token) */
|
||
url?: string
|
||
/** 本地文件(与 url 二选一,file 优先) */
|
||
file?: File | null
|
||
}>()
|
||
|
||
const containerRef = ref<HTMLDivElement | null>(null)
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
|
||
let renderer: THREE.WebGLRenderer | null = null
|
||
let scene: THREE.Scene | null = null
|
||
let camera: THREE.PerspectiveCamera | null = null
|
||
let controls: OrbitControls | null = null
|
||
let animationId = 0
|
||
let modelRoot: THREE.Object3D | null = null
|
||
let defaultCameraPosition = new THREE.Vector3(2, 2, 2)
|
||
let defaultTarget = new THREE.Vector3(0, 0, 0)
|
||
let disposed = false
|
||
let objectUrls: string[] = []
|
||
|
||
function resolveUrl(url: string) {
|
||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||
if (url.startsWith('/')) return `${window.location.origin}${url}`
|
||
return url
|
||
}
|
||
|
||
async function fetchModelArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||
const userStore = useUserStore()
|
||
const headers: Record<string, string> = {}
|
||
if (userStore.token) headers.Authorization = userStore.token
|
||
|
||
const res = await fetch(resolveUrl(url), { headers })
|
||
if (!res.ok) throw new Error(`模型加载失败 (${res.status})`)
|
||
return res.arrayBuffer()
|
||
}
|
||
|
||
function initScene() {
|
||
const container = containerRef.value
|
||
if (!container) return
|
||
|
||
const width = container.clientWidth || 640
|
||
const height = container.clientHeight || 480
|
||
|
||
scene = new THREE.Scene()
|
||
scene.background = new THREE.Color(0xf0f2f5)
|
||
|
||
camera = new THREE.PerspectiveCamera(45, width / height, 0.01, 10000)
|
||
camera.position.copy(defaultCameraPosition)
|
||
|
||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||
renderer.setSize(width, height)
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||
container.innerHTML = ''
|
||
container.appendChild(renderer.domElement)
|
||
|
||
controls = new OrbitControls(camera, renderer.domElement)
|
||
controls.enableDamping = true
|
||
controls.dampingFactor = 0.08
|
||
controls.target.copy(defaultTarget)
|
||
|
||
const ambient = new THREE.AmbientLight(0xffffff, 0.75)
|
||
const dir1 = new THREE.DirectionalLight(0xffffff, 1.1)
|
||
dir1.position.set(5, 8, 6)
|
||
const dir2 = new THREE.DirectionalLight(0xffffff, 0.45)
|
||
dir2.position.set(-4, -2, -5)
|
||
scene.add(ambient, dir1, dir2)
|
||
|
||
const grid = new THREE.GridHelper(10, 20, 0xcccccc, 0xe8e8e8)
|
||
scene.add(grid)
|
||
|
||
animate()
|
||
}
|
||
|
||
function animate() {
|
||
animationId = requestAnimationFrame(animate)
|
||
controls?.update()
|
||
if (renderer && scene && camera) renderer.render(scene, camera)
|
||
}
|
||
|
||
function frameModel(object: THREE.Object3D) {
|
||
if (!camera || !controls) return
|
||
const box = new THREE.Box3().setFromObject(object)
|
||
if (box.isEmpty()) return
|
||
|
||
const size = box.getSize(new THREE.Vector3())
|
||
const center = box.getCenter(new THREE.Vector3())
|
||
const maxDim = Math.max(size.x, size.y, size.z, 0.001)
|
||
const dist = maxDim * 1.8 / Math.tan((camera.fov * Math.PI) / 360)
|
||
|
||
defaultTarget.copy(center)
|
||
defaultCameraPosition.set(center.x + dist * 0.6, center.y + dist * 0.5, center.z + dist * 0.8)
|
||
camera.position.copy(defaultCameraPosition)
|
||
camera.near = maxDim / 100
|
||
camera.far = maxDim * 100
|
||
camera.updateProjectionMatrix()
|
||
controls.target.copy(center)
|
||
controls.update()
|
||
}
|
||
|
||
function resetView() {
|
||
if (!camera || !controls) return
|
||
camera.position.copy(defaultCameraPosition)
|
||
controls.target.copy(defaultTarget)
|
||
controls.update()
|
||
}
|
||
|
||
function clearModel() {
|
||
if (modelRoot && scene) {
|
||
scene.remove(modelRoot)
|
||
modelRoot.traverse((child) => {
|
||
const mesh = child as THREE.Mesh
|
||
if (mesh.geometry) mesh.geometry.dispose()
|
||
if (mesh.material) {
|
||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||
mats.forEach(m => m.dispose())
|
||
}
|
||
})
|
||
modelRoot = null
|
||
}
|
||
objectUrls.forEach(u => URL.revokeObjectURL(u))
|
||
objectUrls = []
|
||
}
|
||
|
||
function disposeViewer() {
|
||
disposed = true
|
||
cancelAnimationFrame(animationId)
|
||
clearModel()
|
||
controls?.dispose()
|
||
renderer?.dispose()
|
||
controls = null
|
||
renderer = null
|
||
scene = null
|
||
camera = null
|
||
disposeDracoLoader()
|
||
}
|
||
|
||
async function loadFromFile(file: File) {
|
||
if (disposed) return
|
||
loading.value = true
|
||
error.value = ''
|
||
clearModel()
|
||
|
||
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||
if (ext !== 'glb' && ext !== 'gltf') {
|
||
error.value = '仅支持 .glb / .gltf 文件'
|
||
loading.value = false
|
||
return
|
||
}
|
||
|
||
try {
|
||
if (!scene) initScene()
|
||
const loader = createGLTFLoader()
|
||
|
||
if (ext === 'glb') {
|
||
const buffer = await file.arrayBuffer()
|
||
const gltf = await new Promise<{ scene: THREE.Object3D }>((resolve, reject) => {
|
||
loader.parse(buffer, '', resolve, reject)
|
||
})
|
||
modelRoot = gltf.scene
|
||
} else {
|
||
const objectUrl = URL.createObjectURL(file)
|
||
objectUrls.push(objectUrl)
|
||
const gltf = await new Promise<{ scene: THREE.Object3D }>((resolve, reject) => {
|
||
loader.load(objectUrl, (g) => resolve(g), undefined, reject)
|
||
})
|
||
modelRoot = gltf.scene
|
||
}
|
||
|
||
scene!.add(modelRoot!)
|
||
frameModel(modelRoot!)
|
||
} catch (e) {
|
||
error.value = ext === 'gltf'
|
||
? 'gltf 加载失败;若含外部 .bin 请改用单文件 .glb'
|
||
: (e instanceof Error ? e.message : '模型加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadModel(url: string) {
|
||
if (!url || disposed) return
|
||
loading.value = true
|
||
error.value = ''
|
||
clearModel()
|
||
|
||
try {
|
||
if (!scene) initScene()
|
||
const buffer = await fetchModelArrayBuffer(url)
|
||
const loader = createGLTFLoader()
|
||
const gltf = await new Promise<{ scene: THREE.Object3D }>((resolve, reject) => {
|
||
loader.parse(buffer, '', resolve, reject)
|
||
})
|
||
modelRoot = gltf.scene
|
||
scene!.add(modelRoot)
|
||
frameModel(modelRoot)
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : '模型加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function onResize() {
|
||
const container = containerRef.value
|
||
if (!container || !renderer || !camera) return
|
||
const width = container.clientWidth
|
||
const height = container.clientHeight
|
||
if (!width || !height) return
|
||
camera.aspect = width / height
|
||
camera.updateProjectionMatrix()
|
||
renderer.setSize(width, height)
|
||
}
|
||
|
||
watch(
|
||
() => [props.file, props.url] as const,
|
||
([file, url]) => {
|
||
if (file) {
|
||
loadFromFile(file)
|
||
} else if (url) {
|
||
loadModel(url)
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('resize', onResize)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', onResize)
|
||
disposeViewer()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cad-model-viewer {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 420px;
|
||
background: #f0f2f5;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.cad-model-viewer__canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 420px;
|
||
}
|
||
|
||
.cad-model-viewer__canvas :deep(canvas) {
|
||
display: block;
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
}
|
||
|
||
.cad-model-viewer__overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
background: rgba(255, 255, 255, 0.85);
|
||
z-index: 2;
|
||
}
|
||
|
||
.cad-model-viewer__overlay--error {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
}
|
||
|
||
.cad-model-viewer__toolbar {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
z-index: 3;
|
||
}
|
||
</style>
|