438 lines
11 KiB
Vue
438 lines
11 KiB
Vue
<!--
|
||
* @Description: 模型与Web的交互
|
||
* @Autor: 刘 相卿
|
||
* @Date: 2025-11-17 16:07:33
|
||
* @LastEditors: 刘 相卿
|
||
* @LastEditTime: 2025-11-21 10:04:12
|
||
-->
|
||
<script lang="ts" setup>
|
||
import * as THREE from 'three'
|
||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
|
||
import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js'
|
||
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
|
||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||
|
||
// 物体信息类型
|
||
interface ObjectInfo {
|
||
name: string
|
||
material: string
|
||
position: THREE.Vector3
|
||
mesh: THREE.Mesh
|
||
}
|
||
|
||
const threeRef = shallowRef<HTMLDivElement>()
|
||
|
||
let scene: THREE.Scene
|
||
let camera: THREE.PerspectiveCamera
|
||
let renderer: THREE.WebGLRenderer
|
||
let controls: OrbitControls
|
||
let animationId: number
|
||
let raycaster: THREE.Raycaster
|
||
let mouse: THREE.Vector2
|
||
let model: THREE.Group
|
||
|
||
// 后期处理相关
|
||
let composer: EffectComposer
|
||
let outlinePass: OutlinePass
|
||
|
||
// 交互状态
|
||
const hoveredObject = shallowRef<THREE.Mesh | null>(null)
|
||
const selectedObject = ref<ObjectInfo | null>(null)
|
||
const labelPosition = ref({ x: 0, y: 0 })
|
||
|
||
// 辅助线
|
||
let axesHelper: THREE.AxesHelper | null = null
|
||
|
||
function init() {
|
||
const width = threeRef.value!.clientWidth
|
||
const height = threeRef.value!.clientHeight
|
||
|
||
scene = new THREE.Scene()
|
||
scene.background = new THREE.Color(0x1A1A2E)
|
||
|
||
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
|
||
camera.position.set(0, 5, 15)
|
||
|
||
renderer = new THREE.WebGLRenderer({ antialias: true })
|
||
renderer.setSize(width, height)
|
||
renderer.setPixelRatio(window.devicePixelRatio)
|
||
threeRef.value!.appendChild(renderer.domElement)
|
||
|
||
controls = new OrbitControls(camera, renderer.domElement)
|
||
controls.enableDamping = true
|
||
controls.dampingFactor = 0.05
|
||
|
||
// 射线检测器
|
||
raycaster = new THREE.Raycaster()
|
||
mouse = new THREE.Vector2()
|
||
|
||
// 添加灯光 - 增强照明
|
||
// 环境光 - 提供基础亮度
|
||
const ambientLight = new THREE.AmbientLight(0xFFFFFF, 5)
|
||
scene.add(ambientLight)
|
||
|
||
// 主方向光 - 从右上方照射
|
||
const mainLight = new THREE.DirectionalLight(0xFFFFFF, 1.5)
|
||
mainLight.position.set(10, 15, 10)
|
||
mainLight.castShadow = true
|
||
scene.add(mainLight)
|
||
|
||
// 辅助方向光 - 从左侧照射,补充阴影区域
|
||
const fillLight = new THREE.DirectionalLight(0xFFFFFF, 0.8)
|
||
fillLight.position.set(-10, 10, -10)
|
||
scene.add(fillLight)
|
||
|
||
// 背光 - 从后方照射,增加轮廓感
|
||
const backLight = new THREE.DirectionalLight(0xFFFFFF, 0.6)
|
||
backLight.position.set(0, 5, -15)
|
||
scene.add(backLight)
|
||
|
||
// 点光源 - 增加局部亮度
|
||
const pointLight = new THREE.PointLight(0xFFFFFF, 1, 50)
|
||
pointLight.position.set(0, 10, 0)
|
||
scene.add(pointLight)
|
||
|
||
// 添加地面
|
||
const groundGeometry = new THREE.PlaneGeometry(100, 100)
|
||
const groundMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x555555,
|
||
roughness: 0.8,
|
||
metalness: 0.2,
|
||
})
|
||
const ground = new THREE.Mesh(groundGeometry, groundMaterial)
|
||
ground.rotation.x = -Math.PI / 2
|
||
ground.position.y = 0
|
||
scene.add(ground)
|
||
|
||
// 设置后期处理
|
||
setupPostProcessing()
|
||
|
||
// 加载模型
|
||
const loader = new GLTFLoader()
|
||
loader.load('/mzjc_bansw.glb', (gltf) => {
|
||
model = gltf.scene
|
||
model.traverse((child) => {
|
||
if (child instanceof THREE.Mesh) {
|
||
child.userData.objectType = '建筑构件'
|
||
child.userData.objectName = child.name || '未命名物体'
|
||
}
|
||
})
|
||
scene.add(model)
|
||
})
|
||
|
||
// 事件监听
|
||
renderer.domElement.addEventListener('mousemove', onMouseMove)
|
||
renderer.domElement.addEventListener('click', onCanvasClick)
|
||
window.addEventListener('resize', onResize)
|
||
|
||
animate()
|
||
}
|
||
|
||
// 设置后期处理(轮廓描边)
|
||
function setupPostProcessing() {
|
||
composer = new EffectComposer(renderer)
|
||
|
||
const renderPass = new RenderPass(scene, camera)
|
||
composer.addPass(renderPass)
|
||
|
||
outlinePass = new OutlinePass(
|
||
new THREE.Vector2(threeRef.value!.clientWidth, threeRef.value!.clientHeight),
|
||
scene,
|
||
camera,
|
||
)
|
||
outlinePass.edgeStrength = 3
|
||
outlinePass.edgeGlow = 0.5
|
||
outlinePass.edgeThickness = 2
|
||
outlinePass.pulsePeriod = 0
|
||
outlinePass.visibleEdgeColor.set('#00ff00')
|
||
outlinePass.hiddenEdgeColor.set('#00ff00')
|
||
|
||
composer.addPass(outlinePass)
|
||
}
|
||
|
||
function animate() {
|
||
animationId = requestAnimationFrame(animate)
|
||
controls.update()
|
||
|
||
// 更新标签位置
|
||
if (selectedObject.value) {
|
||
updateLabelPosition()
|
||
}
|
||
|
||
composer.render()
|
||
}
|
||
|
||
// 更新标签位置
|
||
function updateLabelPosition() {
|
||
if (!selectedObject.value)
|
||
return
|
||
|
||
const position = selectedObject.value.position.clone()
|
||
// 获取物体的包围盒来计算高度
|
||
const box = new THREE.Box3().setFromObject(selectedObject.value.mesh)
|
||
const height = box.max.y - box.min.y
|
||
position.y = box.max.y + height * 0.3
|
||
|
||
position.project(camera)
|
||
|
||
labelPosition.value = {
|
||
x: (position.x * 0.5 + 0.5) * threeRef.value!.clientWidth,
|
||
y: (-(position.y * 0.5) + 0.5) * threeRef.value!.clientHeight,
|
||
}
|
||
}
|
||
|
||
// 鼠标移动事件
|
||
function onMouseMove(event: MouseEvent) {
|
||
const rect = renderer.domElement.getBoundingClientRect()
|
||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||
|
||
raycaster.setFromCamera(mouse, camera)
|
||
|
||
if (model) {
|
||
const intersects = raycaster.intersectObject(model, true)
|
||
|
||
if (intersects.length > 0 && intersects[0]) {
|
||
const object = intersects[0].object as THREE.Mesh
|
||
|
||
// 如果是新的悬停物体
|
||
if (hoveredObject.value !== object) {
|
||
hoveredObject.value = object
|
||
|
||
// 添加轮廓描边
|
||
outlinePass.selectedObjects = [object]
|
||
|
||
// 添加辅助坐标线
|
||
removeAxesHelper()
|
||
const box = new THREE.Box3().setFromObject(object)
|
||
const center = box.getCenter(new THREE.Vector3())
|
||
axesHelper = new THREE.AxesHelper(2)
|
||
axesHelper.position.copy(center)
|
||
scene.add(axesHelper)
|
||
}
|
||
}
|
||
else {
|
||
// 鼠标没有悬停在物体上
|
||
if (hoveredObject.value && hoveredObject.value !== selectedObject.value?.mesh) {
|
||
clearHoverEffects()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 画布点击事件
|
||
function onCanvasClick(event: MouseEvent) {
|
||
const rect = renderer.domElement.getBoundingClientRect()
|
||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||
|
||
raycaster.setFromCamera(mouse, camera)
|
||
|
||
if (model) {
|
||
const intersects = raycaster.intersectObject(model, true)
|
||
|
||
if (intersects.length > 0 && intersects[0]) {
|
||
const object = intersects[0].object as THREE.Mesh
|
||
const point = intersects[0].point
|
||
|
||
// 获取材质信息
|
||
let materialName = '未知材质'
|
||
if (object.material) {
|
||
if (Array.isArray(object.material)) {
|
||
materialName = object.material.map((m: THREE.Material) => m.type).join(', ')
|
||
}
|
||
else {
|
||
materialName = object.material.type
|
||
}
|
||
}
|
||
|
||
// 设置选中物体
|
||
selectedObject.value = {
|
||
name: object.userData.objectName || object.name || '未命名物体',
|
||
material: materialName,
|
||
position: point.clone(),
|
||
mesh: object,
|
||
}
|
||
|
||
// 保持轮廓描边和坐标线
|
||
hoveredObject.value = object
|
||
outlinePass.selectedObjects = [object]
|
||
|
||
updateLabelPosition()
|
||
}
|
||
else {
|
||
// 点击空白处,清除所有效果
|
||
clearAllEffects()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清除悬停效果
|
||
function clearHoverEffects() {
|
||
hoveredObject.value = null
|
||
if (!selectedObject.value) {
|
||
outlinePass.selectedObjects = []
|
||
removeAxesHelper()
|
||
}
|
||
}
|
||
|
||
// 清除所有效果
|
||
function clearAllEffects() {
|
||
hoveredObject.value = null
|
||
selectedObject.value = null
|
||
outlinePass.selectedObjects = []
|
||
removeAxesHelper()
|
||
}
|
||
|
||
// 移除辅助坐标线
|
||
function removeAxesHelper() {
|
||
if (axesHelper) {
|
||
scene.remove(axesHelper)
|
||
axesHelper = null
|
||
}
|
||
}
|
||
|
||
function onResize() {
|
||
if (!threeRef.value)
|
||
return
|
||
const width = threeRef.value.clientWidth
|
||
const height = threeRef.value.clientHeight
|
||
camera.aspect = width / height
|
||
camera.updateProjectionMatrix()
|
||
renderer.setSize(width, height)
|
||
composer.setSize(width, height)
|
||
}
|
||
|
||
onMounted(init)
|
||
onUnmounted(() => {
|
||
cancelAnimationFrame(animationId)
|
||
window.removeEventListener('resize', onResize)
|
||
if (renderer.domElement) {
|
||
renderer.domElement.removeEventListener('mousemove', onMouseMove)
|
||
renderer.domElement.removeEventListener('click', onCanvasClick)
|
||
}
|
||
renderer.dispose()
|
||
composer.dispose()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="threeRef" class="w-full h-full" />
|
||
|
||
<!-- 物体信息标签 -->
|
||
<div
|
||
v-if="selectedObject"
|
||
class="object-label"
|
||
:style="{
|
||
left: `${labelPosition.x}px`,
|
||
top: `${labelPosition.y}px`,
|
||
}"
|
||
>
|
||
<div class="label-content">
|
||
<div class="label-title">
|
||
{{ selectedObject.name }}
|
||
</div>
|
||
<div class="label-item">
|
||
<span class="label-key">材质:</span>
|
||
<span class="label-value">{{ selectedObject.material }}</span>
|
||
</div>
|
||
<div class="label-item">
|
||
<span class="label-key">坐标:</span>
|
||
<span class="label-value">
|
||
({{ selectedObject.position.x.toFixed(2) }},
|
||
{{ selectedObject.position.y.toFixed(2) }},
|
||
{{ selectedObject.position.z.toFixed(2) }})
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<router-link to="/three/11" class="position-fixed left-20px top-20px link-btn">
|
||
后期处理效果
|
||
</router-link>
|
||
|
||
<div class="position-fixed right-20px top-20px info-panel">
|
||
<div class="info-text">
|
||
鼠标移入物体:显示轮廓和坐标轴
|
||
</div>
|
||
<div class="info-text">
|
||
点击物体:显示详细信息
|
||
</div>
|
||
<div class="info-text">
|
||
点击空白处:取消选择
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.object-label {
|
||
position: absolute;
|
||
transform: translate(-50%, -100%);
|
||
pointer-events: none;
|
||
z-index: 100;
|
||
animation: fadeIn 0.3s ease-in-out;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translate(-50%, -100%) scale(0.8);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translate(-50%, -100%) scale(1);
|
||
}
|
||
}
|
||
|
||
.label-content {
|
||
background: rgba(0, 0, 0, 0.85);
|
||
color: white;
|
||
padding: 12px 16px;
|
||
border-radius: 6px;
|
||
border: 2px solid #00ff00;
|
||
box-shadow: 0 4px 12px rgba(0, 255, 0, 0.3);
|
||
min-width: 200px;
|
||
}
|
||
|
||
.label-title {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
margin-bottom: 8px;
|
||
color: #00ff00;
|
||
border-bottom: 1px solid rgba(0, 255, 0, 0.3);
|
||
padding-bottom: 6px;
|
||
}
|
||
|
||
.label-item {
|
||
font-size: 13px;
|
||
margin: 6px 0;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.label-key {
|
||
color: #aaa;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.label-value {
|
||
color: #fff;
|
||
}
|
||
|
||
.info-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.info-text {
|
||
background: rgba(0, 0, 0, 0.75);
|
||
color: white;
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
border-left: 3px solid #00ff00;
|
||
}
|
||
</style>
|