模型显示

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>
<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) //1DPI
// 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
// 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() {
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>

View File

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