Files
DianZhanDemo/app/components/PowerStation/Model3DView.vue
ch197511161 908b4361ed init3
2025-12-11 01:01:11 +08:00

574 lines
15 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.

<!--
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>