mes-vue/src/components/CadModelViewer.vue
2026-06-20 08:47:15 +08:00

309 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>