模型显示
This commit is contained in:
parent
03e4421586
commit
a9ce1a4f77
@ -1,129 +1,228 @@
|
||||
<template>
|
||||
<div class="cad-model-viewer">
|
||||
<div ref="containerRef" class="cad-model-viewer__canvas" />
|
||||
<div v-if="loading" class="cad-model-viewer__overlay">
|
||||
<div class="model-preview" :style="{ width: width, height: height }">
|
||||
<div v-if="loading" class="loading-mask">
|
||||
<n-spin size="large" />
|
||||
<span>模型加载中…</span>
|
||||
<p class="loading-text">模型加载中...</p>
|
||||
</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">
|
||||
<div ref="canvasRef" class="canvas-container"></div>
|
||||
|
||||
<div 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'
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
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'
|
||||
import { STLLoader } from 'three/addons/loaders/STLLoader.js'
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
|
||||
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 远程 glb/gltf 地址(需登录时可带 Token) */
|
||||
url?: string
|
||||
/** 本地文件(与 url 二选一,file 优先) */
|
||||
file?: File | null
|
||||
}>()
|
||||
const props = defineProps({
|
||||
modelUrl: {
|
||||
type: String,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '600px'
|
||||
}
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const emit = defineEmits(['load-success', 'load-error'])
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const loading = ref(true)
|
||||
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
|
||||
let scene, camera, renderer, controls
|
||||
let animationId = null
|
||||
let currentModel = null
|
||||
let dracoLoader
|
||||
let gltfLoader
|
||||
let defaultCameraPosition = new THREE.Vector3(0,0,9)
|
||||
let defaultTarget = new THREE.Vector3(0,0,0)
|
||||
|
||||
|
||||
function resolveUrl(modelUrl) {
|
||||
if (modelUrl.startsWith('http://') || url.startsWith('https://')) return modelUrl
|
||||
if (modelUrl.startsWith('/')) return `${window.location.origin}${modelUrl}`
|
||||
return modelUrl
|
||||
}
|
||||
|
||||
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
|
||||
const initScene = () => {
|
||||
const container = canvasRef.value
|
||||
if (!container) return
|
||||
|
||||
const width = container.clientWidth || 640
|
||||
const height = container.clientHeight || 480
|
||||
const w = container.clientWidth
|
||||
const h = container.clientHeight
|
||||
|
||||
scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(0xf0f2f5)
|
||||
scene = new THREE.Scene() //舞台
|
||||
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 })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.setSize(width, height)
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
container.innerHTML = ''
|
||||
camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10000)
|
||||
// camera = new THREE.OrthographicCamera(45, w / h, 0.1, 10000)
|
||||
camera.position.set(0, 0, 200)
|
||||
|
||||
// 大模型性能降级:关闭抗锯齿、限制像素比、关闭阴影
|
||||
// Renderer 渲染器
|
||||
// 连接场景+相机 输出画面到Canvas画布 调用显卡渲染
|
||||
// WebGLRenderer 硬件加速
|
||||
// WebGPURenderer 新一代高性能渲染
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: true,
|
||||
powerPreference:"low-power" //适应手机端 开启兼容低性能WebGL
|
||||
})
|
||||
renderer.setSize(w, h)
|
||||
renderer.setPixelRatio(1) //手机强制1倍像素,避免高DPI爆现存卡顿
|
||||
// renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1))
|
||||
renderer.shadowMap.enabled = false
|
||||
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 ambientLight = new THREE.AmbientLight(0xffffff, 2.50)//环境光 均匀漫射 无阴影
|
||||
scene.add(ambientLight)
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 1.00)//平行光 太阳光,支持阴影
|
||||
mainLight.position.set(200, 300, 200)
|
||||
scene.add(mainLight)
|
||||
const fillLight = new THREE.DirectionalLight(0xffffff, 0.35)
|
||||
fillLight.position.set(-200, -100, -200)
|
||||
scene.add(fillLight)
|
||||
|
||||
const grid = new THREE.GridHelper(10, 20, 0xcccccc, 0xe8e8e8)
|
||||
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
|
||||
|
||||
// 初始化Draco解码器绑定GLTFLoader
|
||||
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() {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
controls?.update()
|
||||
if (renderer && scene && camera) renderer.render(scene, camera)
|
||||
const clearOldModel = () => {
|
||||
if (!scene) return
|
||||
if (currentModel) {
|
||||
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) {
|
||||
if (!camera || !controls) return
|
||||
const box = new THREE.Box3().setFromObject(object)
|
||||
if (box.isEmpty()) return
|
||||
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const fitModelToView = (model) => {
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
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
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
model.position.sub(center)
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const fov = camera.fov * (Math.PI / 180)
|
||||
const cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 2.2
|
||||
camera.position.set(0, 0, cameraZ)
|
||||
camera.lookAt(0, 0, 0)
|
||||
camera.updateProjectionMatrix()
|
||||
controls.target.copy(center)
|
||||
controls.target.set(0, 0, 0)
|
||||
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() {
|
||||
if (!camera || !controls) return
|
||||
camera.position.copy(defaultCameraPosition)
|
||||
@ -131,178 +230,59 @@ function resetView() {
|
||||
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 },
|
||||
)
|
||||
watch(() => props.modelUrl, (newVal) => newVal && loadModel(), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
initScene()
|
||||
animate()
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
disposeViewer()
|
||||
onUnmounted(() => {
|
||||
if (dracoLoader) dracoLoader.dispose()
|
||||
cancelAnimationFrame(animationId)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
clearOldModel()
|
||||
renderer?.dispose()
|
||||
scene = camera = renderer = controls = dracoLoader = gltfLoader = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cad-model-viewer {
|
||||
.model-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.cad-model-viewer__canvas {
|
||||
.canvas-container {
|
||||
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 {
|
||||
.loading-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
z-index: 2;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.cad-model-viewer__overlay--error {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
.loading-text {
|
||||
margin-top: 12px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cad-model-viewer__toolbar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@ -14,7 +14,7 @@
|
||||
<n-text depth="3">首次查看可能需要 30 秒~数分钟</n-text>
|
||||
</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
|
||||
v-else-if="error"
|
||||
@ -70,8 +70,9 @@ function sleep(ms: number) {
|
||||
|
||||
async function fetchPreview(query: PlmModelPreviewQuery): Promise<PlmModelPreviewResult> {
|
||||
const maxAttempts = 90
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const res = await plmModelApi.getPreview(query)
|
||||
|
||||
if (res.status === 'ready' || res.status === 'failed' || res.status === 'not_found') {
|
||||
return res
|
||||
}
|
||||
@ -86,7 +87,7 @@ async function open(ctx: PlmModelOpenContext) {
|
||||
resultMeta.value = null
|
||||
previewUrl.value = ''
|
||||
error.value = ''
|
||||
converting.value = false
|
||||
converting.value = true
|
||||
loading.value = true
|
||||
visible.value = true
|
||||
|
||||
@ -98,8 +99,10 @@ async function open(ctx: PlmModelOpenContext) {
|
||||
processPlanId: ctx.processPlanId,
|
||||
dispatchId: ctx.dispatchId,
|
||||
})
|
||||
|
||||
resultMeta.value = res
|
||||
|
||||
|
||||
|
||||
if (res.status === 'ready' && res.previewUrl) {
|
||||
previewUrl.value = res.previewUrl
|
||||
} else {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user