Files
vue-model-review/src/components/ThreeViewerDebug.vue
2026-05-12 17:44:15 +08:00

787 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="three-viewer">
<div ref="container" class="canvas-container"></div>
<div class="annotations-container" ref="annotationsContainer"></div>
<!-- 调试面板 -->
<div v-if="debugMode" class="debug-panel">
<h3>调试模式</h3>
<div class="debug-info">
<p>点击模型查看部件信息</p>
<div v-if="selectedMeshInfo">
<p><strong>部件名称:</strong> {{ selectedMeshInfo.name || '未命名' }}</p>
<p><strong>坐标:</strong></p>
<pre>{{ JSON.stringify(selectedMeshInfo.position, null, 2) }}</pre>
<button @click="copyPosition">复制坐标</button>
</div>
</div>
<div class="mesh-list" @click="save">
<h4>模型部件列表:</h4>
<ul>
<li v-for="(mesh, index) in meshList" :key="index">
{{ mesh.name || `未命名_${index}` }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // 导入 GLTFLoader 用于加载 GLTF 模型
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 导入 OrbitControls 用于控制相机
// 高亮
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import TextureName from '../../public/models/textureName.json'
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
import { cloneDeep } from 'lodash';
export default {
name: 'ThreeViewerDebug',
props: {
modelPath: {
type: [String, Number],
default: '',
},
annotations: {
type: Array,
default: () => []
},
debugMode: {
type: Boolean,
default: false
},
viewStr: {
type: Object,
default: () => ({})
}
},
computed: {
textureObj() {
return this.$store && this.$store.state.textureObj || {}
},
},
watch: {
modelPath: {
handler(v) {
if(v) {
if (this.scene) {
this.scene.clear();
this.scene = null;
}
// 2. 销毁渲染器
if (this.renderer) {
this.renderer.dispose();
this.renderer.forceContextLoss();
this.renderer = null;
}
// 3. 销毁相机
this.camera = null;
// 4. 清空画布DOM容器的内容
if(this.sceneDomElement){
this.sceneDomElement.innerHTML = '';
}
this.cleanup();
setTimeout(() => {
this.init()
}, 300)
}
},
immediate: true
},
},
data() {
return {
scene: null, // Three.js场景
camera: null, // 相机
renderer: null, // 渲染器
controls: null, // 轨道控制器
model: null, // 加载的3D模型
frameId: null, // 动画帧ID
sceneDomElement: null, // 容器DOM元素
parent: null, // 容器
maxDim: null,
highlightColor: null, //高亮颜色
composer: null,
pmremGenerator: null,
labelRenderer: null,
annotationObjects: [],
selectedPart: null,
partMeshMap: {},
meshList: [],
selectedMeshInfo: null,
raycaster: new THREE.Raycaster(),
mouse: new THREE.Vector2(),
};
},
mounted () {
// this.onWindowResize(); // 初始化后立即调用一次
// 添加窗口大小变化监听
// window.addEventListener('resize', this.onWindowResize);
this.resizeObserver = new ResizeObserver(this.onWindowResize);
this.resizeObserver.observe(this.$refs.container);
window.addEventListener('click', this.onMouseClick, false);
this.highlightColor = new THREE.Color(0xff0000); // 高亮颜色为红色
},
beforeDestroy() {
this.cleanup();
window.removeEventListener('click', this.onMouseClick, false);
window.removeEventListener('resize', this.onWindowResize);
// 断开ResizeObserver
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.labelRenderer) {
this.labelRenderer.domElement.remove()
}
},
destroyed() {
cancelAnimationFrame(this.frameId);
if (this.sceneDomElement && this.renderer) {
this.sceneDomElement.removeChild(this.renderer.domElement);
}
if (this.renderer) {
this.renderer.dispose();
}
if (this.scene) {
this.scene.clear();
}
},
methods: {
cleanup() {
if (this.frameId) {
cancelAnimationFrame(this.frameId);
}
if (this.scene) {
this.scene.clear();
this.scene = null;
}
if (this.renderer) {
this.renderer.dispose();
this.renderer = null;
}
if (this.labelRenderer) {
this.labelRenderer.domElement.remove();
this.labelRenderer = null;
}
if (this.sceneDomElement) {
this.sceneDomElement.innerHTML = '';
}
},
init() {
if (this.sceneDomElement && this.renderer) {
this.sceneDomElement.removeChild(this.renderer.domElement);
}
// 获取容器元素
this.sceneDomElement = this.$refs.container;
// 1. 创建一个新的场景
this.scene = new THREE.Scene();
// 2. 创建相机
const width = this.sceneDomElement.clientWidth;
const height = this.sceneDomElement.clientHeight;
this.camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 1000);
// 3. 创建渲染器
this.renderer = new THREE.WebGLRenderer({
antialias: true,
// alpha: true,
});
this.renderer.shadowMap.enabled = true
// 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.renderer.setPixelRatio(window.devicePixelRatio);
// 渲染器开启阴影效果
this.renderer.shadowMap.enabled = true
this.renderer.setSize(width, height);
this.renderer.outputEncoding = THREE.sRGBEncoding; // 设置颜色编码
this.sceneDomElement.appendChild(this.renderer.domElement);
this.scene.background = new THREE.Color( 0xcccccc );
this.scene.fog = new THREE.Fog( 0xa0a0a0, 10, 50 );
const hemiLight = new THREE.HemisphereLight( 0xffffff, 0x8d8d8d, 3 );
hemiLight.position.set( 0, 20, 0 );
this.scene.add( hemiLight );
const dirLight = new THREE.DirectionalLight( 0xffffff, 3 );
dirLight.position.set( 3, 10, 10 );
dirLight.castShadow = true;
dirLight.shadow.camera.top = 2;
dirLight.shadow.camera.bottom = - 2;
dirLight.shadow.camera.left = - 2;
dirLight.shadow.camera.right = 2;
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 40;
this.scene.add( dirLight );
// 添加地面
this.createGround()
// 5. 添加轨道控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true; // 启用阻尼效果
this.controls.dampingFactor = 0.25; // 阻尼系数
// 6. 加载3D模型
this.loadModel();
// 7. 开始动画循环
this.animate();
},
loadModel() {
const loader = new GLTFLoader();
const textureLoader = new THREE.TextureLoader();
loader.load(
this.modelPath,
// 'https://skeps-bucket.oss-cn-shenzhen.aliyuncs.com/2026%2FFile%2F4d3a9525-c415-4ab2-b916-d87b4b86ebcbSK010020001001BLQ_%E4%BA%A4%E6%B5%81%E9%81%BF%E9%9B%B7%E5%99%A8%2C%E5%B8%A6%E6%94%AF%E6%92%91%E4%BB%B6%E5%9B%BA%E5%AE%9A%E9%97%B4%E9%9A%99.glb',
(gltf) => {
// 移除旧模型
if (this.model) {
this.scene.remove(this.model);
}
this.model = gltf.scene;
this.scene.add(this.model);
// 收集所有mesh信息
this.partMeshMap = {};
this.meshList = [];
// 遍历模型所有子元素
this.model.traverse((child) => {
if (child.isMesh) {
child.material.emissiveMap = child.material.map;
child.castShadow = true; // 让模型产生阴影
child.receiveShadow = true; // 让模型接受阴影
// child.material.emissive = child.material.color;
let name = child.material.name.split('.')[0]
let path = TextureName[name]
if(path) {
console.log(7777, child, name, path)
if(this.textureObj[name]) {
child.material = this.textureObj[name]
child.material.needsUpdate = true;
} else {
let texture = textureLoader.load(`/models/texture/${encodeURIComponent(path)}.jpg`)
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set( 1, 1 );
let material = null
if(name.includes('金属')) {
material = new THREE.MeshStandardMaterial({
map: texture,
// color: 0x049EF4,
// emissive: 0x000000,
metalness: 0.55, // 金属感
roughness: 0.05 // 粗糙度0为镜面反射
})
} else {
material = new THREE.MeshPhongMaterial( {
// ...child.material,
map: texture,
emissiveMap: child.material.map,
shininess: 0,
} );
}
child.material = material
child.material.needsUpdate = true;
this.textureObj[name] = material
// this.$store.commit('set_textureObj', this.textureObj)
}
}
// 存储mesh引用
const meshInfo = {
name: child.name,
mesh: child,
parent: child.parent
};
this.meshList.push(meshInfo);
if (child.name) {
this.partMeshMap[child.name] = child;
}
}
});
// 计算模型包围盒并居中
const box = new THREE.Box3().setFromObject(this.model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
// 根据模型大小调整相机
const maxDim = Math.max(size.x, size.y, size.z);
this.maxDim = maxDim
this.camera.position.z = this.maxDim * 1;
this.camera.near = maxDim / 100;
this.camera.far = maxDim * 100;
this.camera.updateProjectionMatrix();
// 创建一个父对象用于旋转
// this.parent = new THREE.Object3D(); // 创建一个新的 Object3D 作为父对象
// this.parent.add(this.model); // 将模型添加到父对象
this.scene.add(this.model); // 将父对象添加到场景
// 让控制器聚焦到模型中心 聚焦中心
this.controls.target.copy(center);
this.controls.enableRotate = true;
// this.controls.minPolarAngle = Math.PI / 2; // 限制向下旋转的角度
// this.controls.maxPolarAngle = Math.PI / 2; // 限制向上旋转的角度
this.controls.update();
this.loadCameraState()
// 调整地面位置到模型底部
if (this.ground) {
this.ground.position.y = box.min.y - 0.01
}
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)
}
);
},
animate() {
this.frameId = requestAnimationFrame(this.animate);
if(this.parent && this.isPlay) {
this.parent.rotation.y += 0.001; // 沿 Y 轴旋转父对象
}
// 更新控制器
if (this.controls) {
this.controls.update();
}
// 渲染场景
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
if (this.composer) {
this.composer.render(this.scene, this.camera);
}
if(this.labelRenderer && this.scene && this.camera) {
this.labelRenderer.render(this.scene, this.camera)
}
},
// 响应式调整大小
onWindowResize() {
if (this.camera && this.renderer) {
const width = this.sceneDomElement.clientWidth;
const height = this.sceneDomElement.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
},
outlineObj(selectedObjects) {
let outlinePass;
let renderPass;
let unrealBloomPass;
// 创建一个EffectComposer效果组合器对象然后在该对象上添加后期处理通道。
// 用于模型边缘高亮
this.composer = new EffectComposer(this.renderer);
this.composer.renderTarget1.texture.outputColorSpace = THREE.sRGBEncoding;
this.composer.renderTarget2.texture.outputColorSpace = THREE.sRGBEncoding;
this.composer.renderTarget1.texture.encoding = THREE.sRGBEncoding;
this.composer.renderTarget2.texture.encoding = THREE.sRGBEncoding;
// 新建一个场景通道 为了覆盖到原来的场景上
renderPass = new RenderPass(this.scene, this.camera);
this.composer.addPass(renderPass);
// 物体边缘发光通道
outlinePass = new OutlinePass(
new THREE.Vector2(this.sceneDomElement.clientWidth, this.sceneDomElement.clientHeight),
this.scene,
this.camera,
selectedObjects
);
outlinePass.selectedObjects = selectedObjects;
outlinePass.edgeStrength = 2.0; // 边框的亮度
outlinePass.edgeGlow = 0.5; // 光晕[0,1]
outlinePass.usePatternTexture = false; // 是否使用父级的材质
outlinePass.edgeThickness = 1.0; // 边框宽度
outlinePass.downSampleRatio = 1; // 边框弯曲度
outlinePass.pulsePeriod = 5; // 呼吸闪烁的速度
outlinePass.visibleEdgeColor.set(parseInt(0x00ff00)); // 呼吸显示的颜色
outlinePass.hiddenEdgeColor = new THREE.Color(0, 0, 0); // 呼吸消失的颜色
outlinePass.clear = true;
this.composer.addPass(outlinePass);
// 自定义的着色器通道 作为参数
// effectFXAA = new ShaderPass(FXAAShader);
// effectFXAA.uniforms.resolution.value.set(
// 1 / window.innerWidth,
// 1 / window.innerHeight
// );
// effectFXAA.renderToScreen = true;
// composer.addPass(effectFXAA);
// // 抗锯齿
// smaaPass = new SMAAPass();
// composer.addPass(smaaPass);
// // 发光效果
unrealBloomPass = new UnrealBloomPass();
unrealBloomPass.strength = 0.1;
unrealBloomPass.radius = 0;
unrealBloomPass.threshold = 1;
this.composer.addPass(unrealBloomPass);
// this.scene.background = new THREE.Color(0x1b1824);
},
// 创建标签
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) => {
if(annotation.position) {
let position = annotation.position && annotation.position[0] ? annotation.position[0] : { x: 0, y: 0, z: 0 }
let position2 = annotation.position && annotation.position[1] ? annotation.position[1] : { x: 0, y: 0, z: 0 }
// 创建标注标签
const labelDiv = document.createElement('div')
labelDiv.className = 'annotation-label'
labelDiv.textContent = `${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(position2.x, position2.y, position2.z)
label.userData = { annotation, index }
this.scene.add(label)
this.annotationObjects.push(label)
// 创建连接线
const points = [
new THREE.Vector3(position.x, position.y, position.z),
new THREE.Vector3(position2.x, position2.y, position2.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)
}else {
this.annotationObjects.push('')
this.annotationObjects.push('')
}
})
},
onAnnotationClick(annotation, index) {
this.highlightPart(index)
this.$emit('annotation-click', annotation, index)
},
// 创建地面
createGround() {
// 创建圆形地面几何体,避免菱形格子
const groundGeometry = new THREE.CircleGeometry(25, 64)
// 创建地面材质 - 使用 ShadowMaterial 只显示阴影
const groundMaterial = new THREE.ShadowMaterial({
opacity: 0.25,
color: 0x000000
})
// 创建地面网格
this.ground = new THREE.Mesh(groundGeometry, groundMaterial)
this.ground.rotation.x = -Math.PI / 2 // 旋转地面使其水平
this.ground.position.y = 0
this.ground.receiveShadow = true // 地面接收阴影
this.scene.add(this.ground)
},
highlightPart(index) {
// 重置之前的高亮
if (this.selectedPart !== null) {
this.annotationObjects.forEach((obj, i) => {
if (obj.isLine) {
obj.material.opacity = 0.5
obj.material.color.setHex(0x409eff)
// obj.material.color = 'rgba(64, 158, 255, 0.9)'
} else if (obj.element) {
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
obj.element.style.transform = 'scale(1)'
}
})
}
// 高亮选中的部件
console.log(8888, this.annotationObjects, index)
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)'
}
}
})
},
onMouseClick(event) {
if (!this.debugMode || !this.sceneDomElement) return;
const rect = this.sceneDomElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.model.children, true);
if (intersects.length > 0) {
const mesh = intersects[0].object;
const point = intersects[0].point;
this.selectedMeshInfo = {
name: mesh.name,
position: {
x: parseFloat(point.x.toFixed(3)),
y: parseFloat(point.y.toFixed(3)),
z: parseFloat(point.z.toFixed(3))
}
};
console.log('点击的部件:', this.selectedMeshInfo);
}
// console.log('我点击了')
// // const raycaster = new THREE.Raycaster();
// // const mouse = new THREE.Vector2();
// // // 计算鼠标在屏幕上的位置并转换为Normalized device coordinates (NDC)
// // console.log(this.sceneDomElement.clientWidth, window.innerWidth)
// // mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
// // mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// // // 更新Raycaster的射线位置和方向
// // raycaster.setFromCamera(mouse, this.camera);
// if(!this.sceneDomElement) return
// let getBoundingClientRect = this.sceneDomElement.getBoundingClientRect()
// // 屏幕坐标转标准设备坐标
// let x = ((event.clientX - getBoundingClientRect .left) / this.sceneDomElement.offsetWidth) * 2 - 1;// 标准设备横坐标
// let y = -((event.clientY - getBoundingClientRect .top) / this.sceneDomElement.offsetHeight) * 2 + 1;// 标准设备纵坐标
// let standardVector = new THREE.Vector3(x, y, 1);// 标准设备坐标
// // 标准设备坐标转世界坐标
// let worldVector = standardVector.unproject(this.camera);
// // 射线投射方向单位向量(worldVector坐标减相机位置坐标)
// let ray = worldVector.sub(this.camera.position).normalize();
// // 创建射线投射器对象
// let rayCaster = new THREE.Raycaster(this.camera.position, ray);
// // 计算物体和射线的交点
// const intersects = rayCaster.intersectObjects(this.model.children, true);
// if (intersects.length !== 0 && intersects[0].object instanceof THREE.Mesh) {
// // this.controls.target.copy(intersects[0].point);
// let selectedObject = intersects[0].object;
// let selectedObjects = [];
// selectedObjects.push(selectedObject.parent);
// this.outlineObj(selectedObjects);
// } else {
// console.log('没找到', this.composer)
// this.composer = null
// }
},
copyPosition() {
if (this.selectedMeshInfo) {
const text = JSON.stringify(this.selectedMeshInfo.position, null, 2);
navigator.clipboard.writeText(text).then(() => {
this.$message.success('坐标已复制到剪贴板');
});
}
},
save() {
let str = {
position: this.camera.position.toArray(), // 保存位置
quaternion: this.camera.quaternion.toArray(), // 保存朝向
fov: this.camera.fov, // 如果是透视摄像机,保存焦距
target: this.controls.target.toArray(), // 控制器目标点
};
console.log(9999, JSON.stringify(str))
},
loadCameraState() {
const cameraData = this.viewStr ? this.viewStr : {};
console.log('cameraData', cameraData)
if(cameraData && cameraData.position && cameraData.quaternion) {
console.log(888, cameraData, cloneDeep(this.camera.position))
this.camera.position.fromArray(cameraData.position); // 恢复位置
this.camera.quaternion.fromArray(cameraData.quaternion); // 恢复朝向
if (this.camera instanceof THREE.PerspectiveCamera) { // 如果是透视摄像机,恢复焦距
this.camera.fov = cameraData.fov;
this.camera.updateProjectionMatrix(); // 更新投影矩阵
}
this.controls.target.fromArray(cameraData.target); // 恢复目标点
this.controls.update(); // 更新控制器状态以应用新的目标点(如果有的话)
} else {
this.camera.position.z = this.maxDim * 1;
this.camera.position.x = 0;
this.camera.position.y = 0;
}
}
}
}
</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;
}
.debug-panel {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.95);
padding: 15px;
border-radius: 8px;
max-width: 300px;
max-height: 80%;
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
pointer-events: auto;
}
.debug-panel h3 {
margin: 0 0 10px 0;
font-size: 16px;
color: #303133;
}
.debug-panel h4 {
margin: 10px 0 5px 0;
font-size: 14px;
color: #606266;
}
.debug-info {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #dcdfe6;
}
.debug-info p {
margin: 5px 0;
font-size: 13px;
color: #606266;
}
.debug-info pre {
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
font-size: 12px;
margin: 5px 0;
}
.debug-info button {
margin-top: 8px;
padding: 5px 12px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.debug-info button:hover {
background: #66b1ff;
}
.mesh-list ul {
margin: 0;
padding-left: 20px;
max-height: 300px;
overflow-y: auto;
}
.mesh-list li {
font-size: 12px;
color: #606266;
margin: 3px 0;
word-break: break-all;
}
</style>