Files
DianZhanDemo/app/components/PowerStation/Model3DView.vue

574 lines
15 KiB
Vue
Raw Normal View History

2025-12-11 01:01:11 +08:00
<!--
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>