模型显示

This commit is contained in:
shaoleiliu-netizen123 2026-06-22 08:34:41 +08:00
parent 03e4421586
commit a9ce1a4f77
2 changed files with 224 additions and 241 deletions

View File

@ -1,129 +1,228 @@
<template> <template>
<div class="cad-model-viewer"> <div class="model-preview" :style="{ width: width, height: height }">
<div ref="containerRef" class="cad-model-viewer__canvas" /> <div v-if="loading" class="loading-mask">
<div v-if="loading" class="cad-model-viewer__overlay">
<n-spin size="large" /> <n-spin size="large" />
<span>模型加载中</span> <p class="loading-text">模型加载中...</p>
</div> </div>
<div v-else-if="error" class="cad-model-viewer__overlay cad-model-viewer__overlay--error"> <div ref="canvasRef" class="canvas-container"></div>
<n-empty :description="error" />
</div> <div class="cad-model-viewer__toolbar">
<div v-if="!loading && !error" class="cad-model-viewer__toolbar">
<n-button size="tiny" quaternary @click="resetView">复位</n-button> <n-button size="tiny" quaternary @click="resetView">复位</n-button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { NButton, NSpin, NEmpty } from 'naive-ui'
import * as THREE from 'three' import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { STLLoader } from 'three/addons/loaders/STLLoader.js'
import { createGLTFLoader, disposeDracoLoader } from '@/utils/gltfLoader' import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { useUserStore } from '@/stores/user' import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
const props = defineProps<{ const props = defineProps({
/** 远程 glb/gltf 地址(需登录时可带 Token */ modelUrl: {
url?: string type: String,
/** 本地文件(与 url 二选一file 优先) */ },
file?: File | null width: {
}>() type: String,
default: '100%'
},
height: {
type: String,
default: '600px'
}
})
const containerRef = ref<HTMLDivElement | null>(null) const emit = defineEmits(['load-success', 'load-error'])
const loading = ref(false)
const canvasRef = ref(null)
const loading = ref(true)
const error = ref('') const error = ref('')
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null let scene, camera, renderer, controls
let camera: THREE.PerspectiveCamera | null = null let animationId = null
let controls: OrbitControls | null = null let currentModel = null
let animationId = 0 let dracoLoader
let modelRoot: THREE.Object3D | null = null let gltfLoader
let defaultCameraPosition = new THREE.Vector3(2, 2, 2) let defaultCameraPosition = new THREE.Vector3(0,0,9)
let defaultTarget = new THREE.Vector3(0,0,0) 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 function resolveUrl(modelUrl) {
if (url.startsWith('/')) return `${window.location.origin}${url}` if (modelUrl.startsWith('http://') || url.startsWith('https://')) return modelUrl
return url if (modelUrl.startsWith('/')) return `${window.location.origin}${modelUrl}`
return modelUrl
} }
async function fetchModelArrayBuffer(url: string): Promise<ArrayBuffer> { const initScene = () => {
const userStore = useUserStore() const container = canvasRef.value
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 if (!container) return
const width = container.clientWidth || 640 const w = container.clientWidth
const height = container.clientHeight || 480 const h = container.clientHeight
scene = new THREE.Scene() scene = new THREE.Scene() //
scene.background = new THREE.Color(0xf0f2f5) scene.background = new THREE.Color('#FFFFFF') //
camera = new THREE.PerspectiveCamera(45, width / height, 0.01, 10000) //
camera.position.copy(defaultCameraPosition) //3D:
// 1.PerspectiveCamera ( )
// 2.OrthographicCamera ( )
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10000)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) // camera = new THREE.OrthographicCamera(45, w / h, 0.1, 10000)
renderer.setSize(width, height) camera.position.set(0, 0, 200)
renderer.outputColorSpace = THREE.SRGBColorSpace
container.innerHTML = '' // 齿
// Renderer
// + Canvas
// WebGLRenderer
// WebGPURenderer
renderer = new THREE.WebGLRenderer({
antialias: false,
preserveDrawingBuffer: true,
powerPreference:"low-power" // WebGL
})
renderer.setSize(w, h)
renderer.setPixelRatio(1) //1DPI
// renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1))
renderer.shadowMap.enabled = false
container.appendChild(renderer.domElement) container.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement) //
controls.enableDamping = true const ambientLight = new THREE.AmbientLight(0xffffff, 2.50)//
controls.dampingFactor = 0.08 scene.add(ambientLight)
controls.target.copy(defaultTarget) const mainLight = new THREE.DirectionalLight(0xffffff, 1.00)//
mainLight.position.set(200, 300, 200)
const ambient = new THREE.AmbientLight(0xffffff, 0.75) scene.add(mainLight)
const dir1 = new THREE.DirectionalLight(0xffffff, 1.1) const fillLight = new THREE.DirectionalLight(0xffffff, 0.35)
dir1.position.set(5, 8, 6) fillLight.position.set(-200, -100, -200)
const dir2 = new THREE.DirectionalLight(0xffffff, 0.45) scene.add(fillLight)
dir2.position.set(-4, -2, -5)
scene.add(ambient, dir1, dir2)
const grid = new THREE.GridHelper(10, 20, 0xcccccc, 0xe8e8e8) const grid = new THREE.GridHelper(10, 20, 0xcccccc, 0xe8e8e8)
scene.add(grid) scene.add(grid)
animate()
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.enablePan = true
controls.minDistance = 1
controls.maxDistance = 10000
// DracoGLTFLoader
dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('https://unpkg.com/three@0.160.0/examples/jsm/libs/draco/')
dracoLoader.setDecoderConfig({ type: 'js' })
gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)
loadModel()
} }
function animate() { const clearOldModel = () => {
animationId = requestAnimationFrame(animate) if (!scene) return
controls?.update() if (currentModel) {
if (renderer && scene && camera) renderer.render(scene, camera) scene.remove(currentModel)
currentModel.traverse((child) => {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose())
else child.material.dispose()
}
})
currentModel = null
}
} }
function frameModel(object: THREE.Object3D) { const fitModelToView = (model) => {
if (!camera || !controls) return const box = new THREE.Box3().setFromObject(model)
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 center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z, 0.001) const size = box.getSize(new THREE.Vector3())
const dist = maxDim * 1.8 / Math.tan((camera.fov * Math.PI) / 360) model.position.sub(center)
const maxDim = Math.max(size.x, size.y, size.z)
defaultTarget.copy(center) const fov = camera.fov * (Math.PI / 180)
defaultCameraPosition.set(center.x + dist * 0.6, center.y + dist * 0.5, center.z + dist * 0.8) const cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 2.2
camera.position.copy(defaultCameraPosition) camera.position.set(0, 0, cameraZ)
camera.near = maxDim / 100 camera.lookAt(0, 0, 0)
camera.far = maxDim * 100
camera.updateProjectionMatrix() camera.updateProjectionMatrix()
controls.target.copy(center) controls.target.set(0, 0, 0)
controls.update() controls.update()
} }
const loadModel = () => {
if (!props.modelUrl || !gltfLoader) return
loading.value = true
clearOldModel()
const url = props.modelUrl
const ext = url.split('.').pop().toLowerCase()
if (ext === 'stl') {
const loader = new STLLoader()
loader.load(
url,
(geometry) => {
loading.value = false
geometry.computeVertexNormals()
geometry.center()
const material = new THREE.MeshStandardMaterial({
color: 0xb8bcc2, metalness: 0.25, roughness: 0.75, side: THREE.DoubleSide
})
const mesh = new THREE.Mesh(geometry, material)
currentModel = mesh
scene.add(mesh)
fitModelToView(mesh)
emit('load-success', mesh)
},
(xhr) => console.log(`STL加载 ${(xhr.loaded/xhr.total*100).toFixed(1)}%`),
(err) => { loading.value = false; console.error('STL错误', err); emit('load-error', err) }
)
} else if (ext === 'glb' || ext === 'gltf') {
gltfLoader.load(
url,
(gltf) => {
console.log('✅ 成功进入解析回调,开始处理模型')
loading.value = false
const model = gltf.scene
currentModel = model
fitModelToView(model)
scene.add(model)
emit('load-success', model)
},
//
(xhr) => console.log(`GLB下载进度 ${(xhr.loaded/xhr.total*100).toFixed(1)}%`),
(err) => {
loading.value = false
console.error('GLB加载失败完整报错', err)
emit('load-error', err)
}
)
} else {
loading.value = false
emit('load-error', new Error('仅支持 stl / glb / gltf'))
}
}
const animate = () => {
animationId = requestAnimationFrame(animate)
controls?.update()
renderer?.render(scene, camera)
}
const onWindowResize = () => {
if (!camera || !renderer || !canvasRef.value) return
const w = canvasRef.value.clientWidth
const h = canvasRef.value.clientHeight
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(w, h)
}
function resetView() { function resetView() {
if (!camera || !controls) return if (!camera || !controls) return
camera.position.copy(defaultCameraPosition) camera.position.copy(defaultCameraPosition)
@ -131,174 +230,55 @@ function resetView() {
controls.update() 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() { watch(() => props.modelUrl, (newVal) => newVal && loadModel(), { immediate: true })
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(() => { onMounted(() => {
window.addEventListener('resize', onResize) initScene()
animate()
window.addEventListener('resize', onWindowResize)
}) })
onBeforeUnmount(() => { onUnmounted(() => {
window.removeEventListener('resize', onResize) if (dracoLoader) dracoLoader.dispose()
disposeViewer() cancelAnimationFrame(animationId)
window.removeEventListener('resize', onWindowResize)
clearOldModel()
renderer?.dispose()
scene = camera = renderer = controls = dracoLoader = gltfLoader = null
}) })
</script> </script>
<style scoped> <style scoped>
.cad-model-viewer { .model-preview {
position: relative; position: relative;
width: 100%; border: 1px solid #e5e7eb;
height: 100%; border-radius: 4px;
min-height: 420px;
background: #f0f2f5;
border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #f0f2f5;
} }
.canvas-container {
.cad-model-viewer__canvas {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 420px;
} }
.loading-mask {
.cad-model-viewer__canvas :deep(canvas) {
display: block;
width: 100% !important;
height: 100% !important;
}
.cad-model-viewer__overlay {
position: absolute; position: absolute;
inset: 0; top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.85);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px; z-index: 10;
background: rgba(255, 255, 255, 0.85);
z-index: 2;
} }
.loading-text {
.cad-model-viewer__overlay--error { margin-top: 12px;
background: rgba(255, 255, 255, 0.92); color: #666;
font-size: 14px;
} }
.cad-model-viewer__toolbar { .cad-model-viewer__toolbar {
position: absolute; position: absolute;
top: 8px; top: 8px;

View File

@ -14,7 +14,7 @@
<n-text depth="3">首次查看可能需要 30 数分钟</n-text> <n-text depth="3">首次查看可能需要 30 数分钟</n-text>
</div> </div>
<CadModelViewer v-else-if="previewUrl" :url="previewUrl" class="plm-model-drawer__viewer" /> <CadModelViewer v-else-if="previewUrl" :modelUrl="previewUrl" class="plm-model-drawer__viewer" />
<n-result <n-result
v-else-if="error" v-else-if="error"
@ -72,6 +72,7 @@ async function fetchPreview(query: PlmModelPreviewQuery): Promise<PlmModelPrevie
const maxAttempts = 90 const maxAttempts = 90
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
const res = await plmModelApi.getPreview(query) const res = await plmModelApi.getPreview(query)
if (res.status === 'ready' || res.status === 'failed' || res.status === 'not_found') { if (res.status === 'ready' || res.status === 'failed' || res.status === 'not_found') {
return res return res
} }
@ -86,7 +87,7 @@ async function open(ctx: PlmModelOpenContext) {
resultMeta.value = null resultMeta.value = null
previewUrl.value = '' previewUrl.value = ''
error.value = '' error.value = ''
converting.value = false converting.value = true
loading.value = true loading.value = true
visible.value = true visible.value = true
@ -98,8 +99,10 @@ async function open(ctx: PlmModelOpenContext) {
processPlanId: ctx.processPlanId, processPlanId: ctx.processPlanId,
dispatchId: ctx.dispatchId, dispatchId: ctx.dispatchId,
}) })
resultMeta.value = res resultMeta.value = res
if (res.status === 'ready' && res.previewUrl) { if (res.status === 'ready' && res.previewUrl) {
previewUrl.value = res.previewUrl previewUrl.value = res.previewUrl
} else { } else {