278 lines
7.8 KiB
Vue
278 lines
7.8 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="three-viewer">
|
|||
|
|
<div ref="container" class="canvas-container"></div>
|
|||
|
|
<div class="annotations-container" ref="annotationsContainer"></div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
import * as THREE from 'three'
|
|||
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
|||
|
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
|||
|
|
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
|
|||
|
|
|
|||
|
|
export default {
|
|||
|
|
name: 'ThreeViewer',
|
|||
|
|
props: {
|
|||
|
|
modelPath: {
|
|||
|
|
type: String,
|
|||
|
|
required: true
|
|||
|
|
},
|
|||
|
|
annotations: {
|
|||
|
|
type: Array,
|
|||
|
|
default: () => []
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
data() {
|
|||
|
|
return {
|
|||
|
|
scene: null,
|
|||
|
|
camera: null,
|
|||
|
|
renderer: null,
|
|||
|
|
labelRenderer: null,
|
|||
|
|
controls: null,
|
|||
|
|
model: null,
|
|||
|
|
annotationObjects: [],
|
|||
|
|
selectedPart: null
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
mounted() {
|
|||
|
|
this.initThree()
|
|||
|
|
this.loadModel()
|
|||
|
|
this.animate()
|
|||
|
|
window.addEventListener('resize', this.onWindowResize)
|
|||
|
|
},
|
|||
|
|
beforeDestroy() {
|
|||
|
|
window.removeEventListener('resize', this.onWindowResize)
|
|||
|
|
if (this.renderer) {
|
|||
|
|
this.renderer.dispose()
|
|||
|
|
}
|
|||
|
|
if (this.labelRenderer) {
|
|||
|
|
this.labelRenderer.domElement.remove()
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
methods: {
|
|||
|
|
initThree() {
|
|||
|
|
// 场景
|
|||
|
|
this.scene = new THREE.Scene()
|
|||
|
|
this.scene.background = new THREE.Color(0xf0f0f0)
|
|||
|
|
|
|||
|
|
// 相机
|
|||
|
|
const container = this.$refs.container
|
|||
|
|
const width = container.clientWidth
|
|||
|
|
const height = container.clientHeight
|
|||
|
|
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
|
|||
|
|
this.camera.position.set(5, 5, 5)
|
|||
|
|
|
|||
|
|
// 渲染器
|
|||
|
|
this.renderer = new THREE.WebGLRenderer({ antialias: true })
|
|||
|
|
this.renderer.setSize(width, height)
|
|||
|
|
this.renderer.setPixelRatio(window.devicePixelRatio)
|
|||
|
|
this.renderer.shadowMap.enabled = true
|
|||
|
|
container.appendChild(this.renderer.domElement)
|
|||
|
|
|
|||
|
|
// CSS2D渲染器(用于标注)
|
|||
|
|
this.labelRenderer = new CSS2DRenderer()
|
|||
|
|
this.labelRenderer.setSize(width, height)
|
|||
|
|
this.labelRenderer.domElement.style.position = 'absolute'
|
|||
|
|
this.labelRenderer.domElement.style.top = '0'
|
|||
|
|
this.labelRenderer.domElement.style.pointerEvents = 'none'
|
|||
|
|
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement)
|
|||
|
|
|
|||
|
|
// 控制器
|
|||
|
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
|
|||
|
|
this.controls.enableDamping = true
|
|||
|
|
this.controls.dampingFactor = 0.05
|
|||
|
|
|
|||
|
|
// 光照
|
|||
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
|
|||
|
|
this.scene.add(ambientLight)
|
|||
|
|
|
|||
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
|||
|
|
directionalLight.position.set(5, 10, 5)
|
|||
|
|
directionalLight.castShadow = true
|
|||
|
|
this.scene.add(directionalLight)
|
|||
|
|
|
|||
|
|
// 网格地面
|
|||
|
|
// const gridHelper = new THREE.GridHelper(10, 10)
|
|||
|
|
// this.scene.add(gridHelper)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
loadModel() {
|
|||
|
|
const loader = new GLTFLoader()
|
|||
|
|
console.log(this.modelPath)
|
|||
|
|
loader.load(
|
|||
|
|
this.modelPath,
|
|||
|
|
(gltf) => {
|
|||
|
|
this.model = gltf.scene
|
|||
|
|
|
|||
|
|
// 计算模型边界并居中
|
|||
|
|
const box = new THREE.Box3().setFromObject(this.model)
|
|||
|
|
const center = box.getCenter(new THREE.Vector3())
|
|||
|
|
const size = box.getSize(new THREE.Vector3())
|
|||
|
|
|
|||
|
|
this.model.position.sub(center)
|
|||
|
|
|
|||
|
|
// 调整相机位置
|
|||
|
|
const maxDim = Math.max(size.x, size.y, size.z)
|
|||
|
|
const fov = this.camera.fov * (Math.PI / 180)
|
|||
|
|
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2))
|
|||
|
|
cameraZ *= 2.5
|
|||
|
|
|
|||
|
|
this.camera.position.set(cameraZ, cameraZ, cameraZ)
|
|||
|
|
this.camera.lookAt(0, 0, 0)
|
|||
|
|
this.controls.target.set(0, 0, 0)
|
|||
|
|
|
|||
|
|
this.scene.add(this.model)
|
|||
|
|
this.createAnnotations()
|
|||
|
|
|
|||
|
|
this.$emit('model-loaded')
|
|||
|
|
},
|
|||
|
|
(progress) => {
|
|||
|
|
const percent = (progress.loaded / progress.total) * 100
|
|||
|
|
this.$emit('loading-progress', percent)
|
|||
|
|
},
|
|||
|
|
(error) => {
|
|||
|
|
console.error('模型加载失败:', error)
|
|||
|
|
this.$emit('model-error', error)
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
createAnnotations() {
|
|||
|
|
// 清除旧标注
|
|||
|
|
this.annotationObjects.forEach(obj => {
|
|||
|
|
this.scene.remove(obj)
|
|||
|
|
})
|
|||
|
|
this.annotationObjects = []
|
|||
|
|
|
|||
|
|
// 创建标注点的预设位置(圆形分布)
|
|||
|
|
const radius = 2
|
|||
|
|
const angleStep = (Math.PI * 2) / this.annotations.length
|
|||
|
|
|
|||
|
|
this.annotations.forEach((annotation, index) => {
|
|||
|
|
const angle = angleStep * index
|
|||
|
|
const x = Math.cos(angle) * radius
|
|||
|
|
const z = Math.sin(angle) * radius
|
|||
|
|
const y = 1 + Math.sin(index) * 0.5
|
|||
|
|
|
|||
|
|
// 创建标注标签
|
|||
|
|
const labelDiv = document.createElement('div')
|
|||
|
|
labelDiv.className = 'annotation-label'
|
|||
|
|
labelDiv.textContent = `${index + 1} ${annotation.name}`
|
|||
|
|
labelDiv.style.cssText = `
|
|||
|
|
background: rgba(64, 158, 255, 0.9);
|
|||
|
|
color: white;
|
|||
|
|
padding: 5px 10px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
pointer-events: auto;
|
|||
|
|
`
|
|||
|
|
|
|||
|
|
labelDiv.addEventListener('click', () => {
|
|||
|
|
this.onAnnotationClick(annotation, index)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const label = new CSS2DObject(labelDiv)
|
|||
|
|
label.position.set(x, y, z)
|
|||
|
|
label.userData = { annotation, index }
|
|||
|
|
|
|||
|
|
this.scene.add(label)
|
|||
|
|
this.annotationObjects.push(label)
|
|||
|
|
|
|||
|
|
// 创建连接线
|
|||
|
|
const points = [
|
|||
|
|
new THREE.Vector3(0, 0, 0),
|
|||
|
|
new THREE.Vector3(x, y, z)
|
|||
|
|
]
|
|||
|
|
const geometry = new THREE.BufferGeometry().setFromPoints(points)
|
|||
|
|
const material = new THREE.LineBasicMaterial({
|
|||
|
|
color: 0x409eff,
|
|||
|
|
transparent: true,
|
|||
|
|
opacity: 0.5
|
|||
|
|
})
|
|||
|
|
const line = new THREE.Line(geometry, material)
|
|||
|
|
this.scene.add(line)
|
|||
|
|
this.annotationObjects.push(line)
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onAnnotationClick(annotation, index) {
|
|||
|
|
this.highlightPart(index)
|
|||
|
|
this.$emit('annotation-click', annotation, index)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
highlightPart(index) {
|
|||
|
|
// 重置之前的高亮
|
|||
|
|
if (this.selectedPart !== null) {
|
|||
|
|
this.annotationObjects.forEach((obj, i) => {
|
|||
|
|
if (obj.isLine) {
|
|||
|
|
obj.material.opacity = 0.5
|
|||
|
|
} else if (obj.element) {
|
|||
|
|
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
|
|||
|
|
obj.element.style.transform = 'scale(1)'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 高亮选中的部件
|
|||
|
|
this.selectedPart = index
|
|||
|
|
this.annotationObjects.forEach((obj, i) => {
|
|||
|
|
const objIndex = Math.floor(i / 2)
|
|||
|
|
if (objIndex === index) {
|
|||
|
|
if (obj.isLine) {
|
|||
|
|
obj.material.opacity = 1
|
|||
|
|
obj.material.color.setHex(0xff6b00)
|
|||
|
|
} else if (obj.element) {
|
|||
|
|
obj.element.style.background = 'rgba(255, 107, 0, 0.95)'
|
|||
|
|
obj.element.style.transform = 'scale(1.1)'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
animate() {
|
|||
|
|
requestAnimationFrame(this.animate)
|
|||
|
|
this.controls.update()
|
|||
|
|
this.renderer.render(this.scene, this.camera)
|
|||
|
|
this.labelRenderer.render(this.scene, this.camera)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
onWindowResize() {
|
|||
|
|
const container = this.$refs.container
|
|||
|
|
const width = container.clientWidth
|
|||
|
|
const height = container.clientHeight
|
|||
|
|
|
|||
|
|
this.camera.aspect = width / height
|
|||
|
|
this.camera.updateProjectionMatrix()
|
|||
|
|
|
|||
|
|
this.renderer.setSize(width, height)
|
|||
|
|
this.labelRenderer.setSize(width, height)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.three-viewer {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.canvas-container {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.annotations-container {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
</style>
|