574 lines
15 KiB
Vue
574 lines
15 KiB
Vue
<!--
|
||
Model3DView.vue
|
||
3D模型视图组件
|
||
负责加载、渲染和交互3D模型
|
||
-->
|
||
<template>
|
||
<div ref="containerRef" class="w-full h-full relative overflow-hidden">
|
||
<!-- 加载遮罩 -->
|
||
<LoadingOverlay :is-loading="isLoading" />
|
||
|
||
<!-- 工具栏 -->
|
||
<ModelToolbar
|
||
:auto-rotate="autoRotate"
|
||
:is-maximized="isMaximized || false"
|
||
:lighting-mode="lightingMode"
|
||
:double-sided="doubleSided"
|
||
:show-download="isFullMode"
|
||
@reset-view="resetView"
|
||
@toggle-auto-rotate="toggleAutoRotate"
|
||
@toggle-maximize="emit('toggleMaximize')"
|
||
@toggle-lighting="toggleLighting"
|
||
@toggle-double-side="toggleDoubleSide"
|
||
@export-model="exportModel"
|
||
@export-hierarchy="exportHierarchy"
|
||
/>
|
||
|
||
<!-- 视图控制 -->
|
||
<ViewControls @set-standard-view="setStandardView" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
/**
|
||
* Model3DView 组件
|
||
* 3D模型的主视图组件,负责模型的加载、渲染和交互
|
||
*/
|
||
import * as THREE from 'three'
|
||
import { nextTick } from 'vue'
|
||
import { useInteraction } from '~/composables/powerStation/useInteraction'
|
||
import { useModelManager } from '~/composables/powerStation/useModelManager'
|
||
import { useThreeScene } from '~/composables/powerStation/useThreeScene'
|
||
import { usePowerStationStore } from '~/stores/powerStation'
|
||
import LoadingOverlay from './Model3D/LoadingOverlay.vue'
|
||
import ModelToolbar from './Model3D/ModelToolbar.vue'
|
||
import ViewControls from './Model3D/ViewControls.vue'
|
||
|
||
// ============================================================
|
||
// Props 和 Emits
|
||
// ============================================================
|
||
|
||
const props = defineProps<{
|
||
/** 是否最大化 */
|
||
isMaximized?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits(['toggleMaximize'])
|
||
|
||
// ============================================================
|
||
// 状态和引用
|
||
// ============================================================
|
||
|
||
const route = useRoute()
|
||
const store = usePowerStationStore()
|
||
|
||
// ============================================================
|
||
// Three.js 场景系统
|
||
// ============================================================
|
||
|
||
const {
|
||
containerRef,
|
||
initThree,
|
||
getScene,
|
||
getCamera,
|
||
getRenderer,
|
||
getControls,
|
||
getComposer,
|
||
addAnimateCallback,
|
||
addResizeCallback,
|
||
onWindowResize,
|
||
setStandardView,
|
||
} = useThreeScene()
|
||
|
||
// ============================================================
|
||
// 模型管理系统
|
||
// ============================================================
|
||
|
||
const {
|
||
modelGroup,
|
||
isLoading,
|
||
lightingMode,
|
||
objectMap,
|
||
initModelSystem,
|
||
loadModel,
|
||
processModel,
|
||
fitView,
|
||
toggleLighting,
|
||
archetypeModel,
|
||
findNodeById,
|
||
loadModelByFileName,
|
||
} = useModelManager(getScene, getCamera, getControls, getRenderer)
|
||
|
||
// ============================================================
|
||
// 交互系统
|
||
// ============================================================
|
||
|
||
const {
|
||
initInteraction,
|
||
highlightClick,
|
||
updatePassSize,
|
||
dispose: disposeInteraction,
|
||
} = useInteraction(
|
||
getScene,
|
||
getCamera,
|
||
getComposer,
|
||
() => containerRef.value,
|
||
modelGroup,
|
||
() => archetypeModel.value
|
||
)
|
||
|
||
// ============================================================
|
||
// 本地状态
|
||
// ============================================================
|
||
|
||
/** 自动旋转状态 */
|
||
const autoRotate = ref(false)
|
||
/** 双面渲染状态 */
|
||
const doubleSided = ref(false)
|
||
|
||
// ============================================================
|
||
// 计算属性
|
||
// ============================================================
|
||
|
||
/** 是否为完整模式 */
|
||
const isFullMode = computed(() => store.loadMode)
|
||
|
||
// ============================================================
|
||
// 视图控制方法
|
||
// ============================================================
|
||
|
||
/**
|
||
* 切换自动旋转
|
||
*/
|
||
const toggleAutoRotate = () => {
|
||
autoRotate.value = !autoRotate.value
|
||
}
|
||
|
||
/**
|
||
* 切换双面渲染
|
||
* 使用 shader 方式实现,通过修改材质的 side 属性
|
||
*/
|
||
const toggleDoubleSide = () => {
|
||
doubleSided.value = !doubleSided.value
|
||
const scene = getScene()
|
||
if (!scene) return
|
||
|
||
// 遍历场景中所有物体,修改材质的渲染面属性
|
||
scene.traverse((child: THREE.Object3D) => {
|
||
if ((child as THREE.Mesh).isMesh) {
|
||
const mesh = child as THREE.Mesh
|
||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||
|
||
materials.forEach((mat: THREE.Material) => {
|
||
if (doubleSided.value) {
|
||
// 保存原始的 side 值,然后设置为 DoubleSide
|
||
if (mat.userData.originalSide === undefined) {
|
||
mat.userData.originalSide = mat.side
|
||
}
|
||
mat.side = THREE.DoubleSide
|
||
} else {
|
||
// 恢复原始的 side 值
|
||
if (mat.userData.originalSide !== undefined) {
|
||
mat.side = mat.userData.originalSide
|
||
} else {
|
||
mat.side = THREE.FrontSide
|
||
}
|
||
}
|
||
mat.needsUpdate = true
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 重置视图
|
||
*/
|
||
const resetView = async () => {
|
||
const rootPath = store.currentNodeId || archetypeModel.value?.name || 'root'
|
||
await navigateTo({
|
||
query: {
|
||
...route.query,
|
||
currentNodeId: rootPath,
|
||
},
|
||
})
|
||
}
|
||
|
||
// ============================================================
|
||
// 导出功能
|
||
// ============================================================
|
||
|
||
/**
|
||
* 导出GLB模型
|
||
*/
|
||
const exportModel = async () => {
|
||
try {
|
||
if (!modelGroup.children.length) {
|
||
ElMessage.warning('没有可导出的模型')
|
||
return
|
||
}
|
||
|
||
const currentModel = modelGroup.children[0]
|
||
if (!currentModel) {
|
||
ElMessage.warning('未找到当前模型')
|
||
return
|
||
}
|
||
|
||
// 克隆模型用于导出
|
||
const exportModelClone = currentModel.clone()
|
||
|
||
// 清理循环引用
|
||
cleanCircularReferences(exportModelClone)
|
||
|
||
// 转换材质为GLTF兼容格式
|
||
convertMaterialsToGLTFCompatible(exportModelClone)
|
||
|
||
// 生成模型名称
|
||
const modelName = store.currentNodeId || '模型'
|
||
|
||
// 创建导出场景
|
||
const exportScene = new THREE.Scene()
|
||
exportScene.add(exportModelClone)
|
||
|
||
// 使用GLTFExporter导出
|
||
const { GLTFExporter } = await import('three/examples/jsm/exporters/GLTFExporter.js')
|
||
const exporter = new GLTFExporter()
|
||
|
||
exporter.parse(
|
||
exportScene,
|
||
gltf => {
|
||
const glbData = new Blob([gltf], { type: 'model/gltf-binary' })
|
||
downloadBlob(glbData, `${modelName}.glb`)
|
||
ElMessage.success(`模型 ${modelName}.glb 导出成功`)
|
||
},
|
||
error => {
|
||
console.error('导出失败:', error)
|
||
ElMessage.error('模型导出失败')
|
||
},
|
||
{ binary: true }
|
||
)
|
||
} catch (error) {
|
||
console.error('导出错误:', error)
|
||
ElMessage.error('导出过程中发生错误')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 导出层级关系JSON
|
||
*/
|
||
const exportHierarchy = () => {
|
||
try {
|
||
if (!modelGroup.children.length) {
|
||
ElMessage.warning('没有可导出的模型层级')
|
||
return
|
||
}
|
||
|
||
if (!archetypeModel.value) {
|
||
ElMessage.warning('未找到archetypeModel')
|
||
return
|
||
}
|
||
|
||
const modelName = store.currentNodeId || '模型'
|
||
const hierarchyData = buildHierarchy(archetypeModel.value)
|
||
const jsonData = JSON.stringify(hierarchyData, null, 2)
|
||
|
||
const blob = new Blob([jsonData], { type: 'application/json' })
|
||
downloadBlob(blob, `${modelName}~hierarchy.json`)
|
||
|
||
ElMessage.success(`物体层级关系 ${modelName}~hierarchy.json 导出成功`)
|
||
} catch (error) {
|
||
console.error('导出层级关系错误:', error)
|
||
ElMessage.error('导出层级关系失败')
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 导出辅助函数
|
||
// ============================================================
|
||
|
||
/**
|
||
* 清理循环引用
|
||
*/
|
||
const cleanCircularReferences = (object: THREE.Object3D) => {
|
||
object.traverse(child => {
|
||
if ((child as THREE.Mesh).isMesh) {
|
||
const mesh = child as THREE.Mesh
|
||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||
|
||
materials.forEach(mat => {
|
||
if (mat.userData) {
|
||
delete mat.userData.lambertPartner
|
||
delete mat.userData.pbrPartner
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 转换材质为GLTF兼容格式
|
||
*/
|
||
const convertMaterialsToGLTFCompatible = (object: THREE.Object3D) => {
|
||
object.traverse(child => {
|
||
if ((child as THREE.Mesh).isMesh) {
|
||
const mesh = child as THREE.Mesh
|
||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||
|
||
const newMaterials = materials.map(mat => {
|
||
if (mat.type === 'MeshStandardMaterial' || mat.type === 'MeshBasicMaterial') {
|
||
return mat
|
||
}
|
||
|
||
const originalMap = (mat as any).map || null
|
||
const useAlpha = originalMap !== null
|
||
|
||
const standardMat = new THREE.MeshStandardMaterial({
|
||
color: (mat as any).color || new THREE.Color(0xaaaaaa),
|
||
map: originalMap,
|
||
alphaMap: originalMap,
|
||
normalMap: (mat as any).normalMap || null,
|
||
transparent: useAlpha || (mat as any).transparent || false,
|
||
opacity: useAlpha ? 1 : (mat as any).opacity || 1,
|
||
side: mat.side || THREE.FrontSide,
|
||
metalness: 0.6,
|
||
roughness: 0.6,
|
||
})
|
||
|
||
if (mat.userData) {
|
||
standardMat.userData = { ...mat.userData }
|
||
}
|
||
|
||
return standardMat
|
||
})
|
||
|
||
mesh.material = Array.isArray(mesh.material) ? newMaterials : newMaterials[0]
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 构建层级结构
|
||
*/
|
||
const buildHierarchy = (object: THREE.Object3D): any => {
|
||
const obj: any = {
|
||
id: object.uuid,
|
||
name: object.name,
|
||
type: object.type,
|
||
children: [],
|
||
}
|
||
|
||
object.children.forEach(child => {
|
||
obj.children.push(buildHierarchy(child))
|
||
})
|
||
|
||
return obj
|
||
}
|
||
|
||
/**
|
||
* 下载Blob文件
|
||
*/
|
||
const downloadBlob = (blob: Blob, filename: string) => {
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
// ============================================================
|
||
// 监听器
|
||
// ============================================================
|
||
|
||
// 监听最大化状态变化
|
||
watch(
|
||
() => props.isMaximized,
|
||
() => {
|
||
setTimeout(() => {
|
||
onWindowResize()
|
||
fitView()
|
||
}, 350)
|
||
}
|
||
)
|
||
|
||
// 路由监听器(核心逻辑入口)
|
||
watch(
|
||
() => route.query.currentNodeId,
|
||
newId => {
|
||
const id = newId as string
|
||
console.log('路由currentNodeId变化:', id)
|
||
|
||
// 确保store的currentNodeId与路由参数一致
|
||
if (id) {
|
||
store.currentNodeId = id
|
||
store.selectNode({ id })
|
||
} else {
|
||
store.currentNodeId = ''
|
||
store.selectNode({ id: '' })
|
||
}
|
||
|
||
if (isFullMode.value) {
|
||
// 完整模式:处理模型
|
||
if (archetypeModel.value) {
|
||
processModel(id, true)
|
||
}
|
||
} else {
|
||
// 简化模式:直接加载对应文件
|
||
if (id) {
|
||
loadModelByFileName(id)
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
// 选择监听器
|
||
watch(
|
||
() => store.currentNodeId,
|
||
async newId => {
|
||
console.log('选择监听器触发:', newId)
|
||
|
||
if (!newId) {
|
||
highlightClick(null)
|
||
return
|
||
}
|
||
|
||
const currentRouteNodeId = route.query.currentNodeId as string
|
||
|
||
// 等待模型加载完成
|
||
let retryCount = 0
|
||
const maxRetries = 5
|
||
while (isLoading.value && retryCount < maxRetries) {
|
||
console.log(`模型加载中,等待加载完成后再处理高亮 (重试 ${retryCount + 1}/${maxRetries})`)
|
||
await nextTick()
|
||
await new Promise(resolve => setTimeout(resolve, 100))
|
||
retryCount++
|
||
}
|
||
|
||
let obj = objectMap.get(newId)
|
||
console.log('直接找到物体:', obj ? obj.name : 'null')
|
||
|
||
// 如果未找到精确对象,尝试查找可见的祖先
|
||
if (!obj && archetypeModel.value) {
|
||
console.log('搜索可见祖先...')
|
||
let current = findNodeById(archetypeModel.value, newId)
|
||
while (current) {
|
||
console.log('检查祖先:', current.name, current.userData.id)
|
||
if (current.userData.id && objectMap.has(current.userData.id)) {
|
||
obj = objectMap.get(current.userData.id)
|
||
console.log('找到可见祖先:', obj?.name)
|
||
break
|
||
}
|
||
current = current.parent
|
||
}
|
||
}
|
||
|
||
if (obj) {
|
||
highlightClick(obj)
|
||
} else {
|
||
console.warn('未找到ID对应的对象:', newId)
|
||
highlightClick(null)
|
||
}
|
||
|
||
if (newId === currentRouteNodeId) {
|
||
highlightClick(null)
|
||
return
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
// ============================================================
|
||
// 聚焦事件处理
|
||
// ============================================================
|
||
|
||
/**
|
||
* 处理聚焦事件
|
||
*/
|
||
const handleFocusEvent = (nodeId: string) => {
|
||
console.log('聚焦事件触发:', nodeId)
|
||
|
||
let obj = objectMap.get(nodeId)
|
||
|
||
// 如果未找到精确对象,尝试查找可见的祖先
|
||
if (!obj && archetypeModel.value) {
|
||
console.log('搜索可见祖先...')
|
||
let current = findNodeById(archetypeModel.value, nodeId)
|
||
while (current) {
|
||
console.log('检查祖先:', current.name, current.userData.id)
|
||
if (current.userData.id && objectMap.has(current.userData.id)) {
|
||
obj = objectMap.get(current.userData.id)
|
||
console.log('找到可见祖先:', obj?.name)
|
||
break
|
||
}
|
||
current = current.parent
|
||
}
|
||
}
|
||
|
||
if (obj) {
|
||
highlightClick(obj)
|
||
fitView(obj, true)
|
||
console.log('已聚焦到物体:', obj.name)
|
||
} else {
|
||
console.warn('未找到ID对应的对象:', nodeId)
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 生命周期
|
||
// ============================================================
|
||
|
||
onMounted(() => {
|
||
// 1. 初始化Three.js
|
||
initThree()
|
||
|
||
// 2. 初始化模型系统
|
||
initModelSystem()
|
||
|
||
// 3. 初始化交互
|
||
initInteraction()
|
||
|
||
const currentId = route.query.currentNodeId as string
|
||
|
||
// 4. 根据模式加载模型
|
||
if (isFullMode.value) {
|
||
loadModel(`/3DModels/${currentId}.glb`, () => {
|
||
const currentId =
|
||
(route.query.currentNodeId as string) ||
|
||
store.currentNodeId ||
|
||
archetypeModel.value?.name ||
|
||
'root'
|
||
processModel(currentId, true)
|
||
})
|
||
} else {
|
||
loadModelByFileName(currentId)
|
||
}
|
||
|
||
// 5. 设置动画循环钩子
|
||
addAnimateCallback(() => {
|
||
const controls = getControls()
|
||
if (controls) {
|
||
controls.autoRotate = autoRotate.value
|
||
}
|
||
})
|
||
|
||
// 6. 设置尺寸调整钩子
|
||
addResizeCallback(() => {
|
||
if (containerRef.value) {
|
||
const width = containerRef.value.clientWidth
|
||
const height = containerRef.value.clientHeight
|
||
updatePassSize(width, height)
|
||
}
|
||
})
|
||
|
||
// 7. 添加聚焦事件监听器
|
||
store.addFocusListener(handleFocusEvent)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
disposeInteraction()
|
||
store.removeFocusListener(handleFocusEvent)
|
||
})
|
||
</script>
|