552 lines
17 KiB
TypeScript
552 lines
17 KiB
TypeScript
/**
|
||
* 模型管理系统
|
||
* 处理3D模型的加载、层级结构生成、材质处理和相机适配
|
||
*/
|
||
import * as TWEEN from '@tweenjs/tween.js'
|
||
import * as THREE from 'three'
|
||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
|
||
import { usePowerStationStore } from '~/stores/powerStation'
|
||
import { useFullMode } from './modes/useFullMode'
|
||
import { useSimplifiedMode } from './modes/useSimplifiedMode'
|
||
import type { BreadcrumbItem, LightingMode, ModelHierarchyNode } from './types'
|
||
|
||
// ============================================================
|
||
// 配置常量
|
||
// ============================================================
|
||
|
||
/** 光源配置 */
|
||
const LIGHT_CONFIG = {
|
||
/** 环境光颜色 */
|
||
ambientColor: 0xffffff,
|
||
/** 环境光强度 */
|
||
ambientIntensity: 0.6,
|
||
/** 方向光颜色 */
|
||
directionalColor: 0xffffff,
|
||
/** 方向光强度 */
|
||
directionalIntensity: 1,
|
||
/** 方向光位置 */
|
||
directionalPosition: new THREE.Vector3(5, 3, 5),
|
||
} as const
|
||
|
||
/** PBR材质配置 */
|
||
const PBR_MATERIAL_CONFIG = {
|
||
/** 金属度 */
|
||
metalness: 0.6,
|
||
/** 粗糙度 */
|
||
roughness: 0.6,
|
||
/** 环境贴图强度 */
|
||
envMapIntensity: 1.5,
|
||
} as const
|
||
|
||
/** 相机动画配置 */
|
||
const CAMERA_ANIMATION_CONFIG = {
|
||
/** 动画时长(毫秒) */
|
||
duration: 900,
|
||
/** 距离系数 */
|
||
distanceMultiplier: 1.2,
|
||
} as const
|
||
|
||
// ============================================================
|
||
// 主钩子函数
|
||
// ============================================================
|
||
|
||
/**
|
||
* 模型管理组合式函数
|
||
* @param getScene - 获取场景
|
||
* @param getCamera - 获取相机
|
||
* @param getControls - 获取控制器
|
||
* @param getRenderer - 获取渲染器
|
||
*/
|
||
export function useModelManager(
|
||
getScene: () => THREE.Scene,
|
||
getCamera: () => THREE.PerspectiveCamera,
|
||
getControls: () => any, // OrbitControls
|
||
getRenderer: () => THREE.WebGLRenderer
|
||
) {
|
||
const store = usePowerStationStore()
|
||
const isLoading = ref(true)
|
||
|
||
// ============================================================
|
||
// 模型数据
|
||
// ============================================================
|
||
|
||
// 使用 markRaw 标记 Three.js 对象为非响应式,防止 Vue DevTools 追踪导致性能问题
|
||
const modelGroup = markRaw(new THREE.Group())
|
||
const archetypeModel = shallowRef<THREE.Object3D | null>(null)
|
||
|
||
// 使用普通 Map(Three.js 对象已是 markRaw,Map 本身不需要响应式)
|
||
const objectMap = new Map<string, THREE.Object3D>()
|
||
const modelCache = new Map<string, THREE.Object3D>()
|
||
|
||
// 环境贴图
|
||
let envTexture: THREE.Texture | null = null
|
||
|
||
// 光照模式
|
||
const lightingMode = ref<LightingMode>('basic')
|
||
|
||
// ============================================================
|
||
// 初始化
|
||
// ============================================================
|
||
|
||
/**
|
||
* 初始化模型系统
|
||
* 添加模型组和基础光源到场景
|
||
*/
|
||
const initModelSystem = () => {
|
||
const scene = getScene()
|
||
if (!scene) return
|
||
|
||
// 添加模型组
|
||
scene.add(modelGroup)
|
||
|
||
// 添加基础光源
|
||
const ambientLight = new THREE.AmbientLight(
|
||
LIGHT_CONFIG.ambientColor,
|
||
LIGHT_CONFIG.ambientIntensity
|
||
)
|
||
scene.add(ambientLight)
|
||
|
||
const dirLight = new THREE.DirectionalLight(
|
||
LIGHT_CONFIG.directionalColor,
|
||
LIGHT_CONFIG.directionalIntensity
|
||
)
|
||
dirLight.position.copy(LIGHT_CONFIG.directionalPosition)
|
||
scene.add(dirLight)
|
||
}
|
||
|
||
/**
|
||
* 初始化环境贴图
|
||
*/
|
||
const initEnvironment = () => {
|
||
const renderer = getRenderer()
|
||
const scene = getScene()
|
||
if (!renderer || !scene) return
|
||
|
||
// 避免重复创建
|
||
if (envTexture) return
|
||
|
||
const pmremGenerator = new THREE.PMREMGenerator(renderer)
|
||
pmremGenerator.compileEquirectangularShader()
|
||
|
||
const roomEnvironment = new RoomEnvironment()
|
||
envTexture = pmremGenerator.fromScene(roomEnvironment).texture
|
||
|
||
if (lightingMode.value === 'advanced') {
|
||
scene.environment = envTexture
|
||
}
|
||
|
||
// 清理临时对象
|
||
roomEnvironment.dispose()
|
||
pmremGenerator.dispose()
|
||
}
|
||
|
||
// ============================================================
|
||
// 层级结构生成
|
||
// ============================================================
|
||
|
||
/**
|
||
* 生成模型层级结构
|
||
* @param object - Three.js 对象
|
||
* @param currentPath - 当前路径
|
||
* @param depth - 当前深度
|
||
* @param maxDepth - 最大深度
|
||
* @returns 层级节点
|
||
*/
|
||
const generateHierarchy = (
|
||
object: THREE.Object3D,
|
||
currentPath: string,
|
||
depth: number = 1,
|
||
maxDepth: number = Infinity
|
||
): ModelHierarchyNode => {
|
||
const isRoot = object === archetypeModel.value
|
||
const nodeId = isRoot ? store.currentNodeId || currentPath : currentPath
|
||
|
||
// 设置 userData.id
|
||
object.userData.id = nodeId
|
||
|
||
const node: ModelHierarchyNode = {
|
||
id: nodeId,
|
||
label: isRoot
|
||
? store.currentNodeId || object.name || `节点 ${nodeId}`
|
||
: object.name || `节点 ${nodeId}`,
|
||
children: [],
|
||
}
|
||
|
||
// 检查是否为完整物体(有名称且包含多个子Mesh)
|
||
const isCompleteObject =
|
||
object.name && object.children.filter(child => child.type === 'Mesh').length > 1
|
||
|
||
// 达到最大深度则停止递归
|
||
if (depth >= maxDepth) {
|
||
return node
|
||
}
|
||
|
||
// 递归处理子节点
|
||
object.children.forEach(child => {
|
||
if (child.type === 'Mesh' || child.type === 'Group' || child.type === 'Object3D') {
|
||
const childName = child.name || '子对象'
|
||
const childPath = `${currentPath}~${childName}`
|
||
|
||
// 跳过完整物体中的无名Mesh子节点
|
||
if (isCompleteObject && child.type === 'Mesh' && !child.name) {
|
||
return
|
||
}
|
||
|
||
const childNode = generateHierarchy(child, childPath, depth + 1, maxDepth)
|
||
node.children.push(childNode)
|
||
}
|
||
})
|
||
|
||
return node
|
||
}
|
||
|
||
// ============================================================
|
||
// 节点查找
|
||
// ============================================================
|
||
|
||
/**
|
||
* 通过ID查找节点
|
||
* @param root - 根对象
|
||
* @param id - 节点ID
|
||
* @returns 找到的节点或null
|
||
*/
|
||
const findNodeById = (root: THREE.Object3D, id: string): THREE.Object3D | null => {
|
||
if (root.userData.id === id) return root
|
||
|
||
for (const child of root.children) {
|
||
const found = findNodeById(child, id)
|
||
if (found) return found
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 重建物体映射表
|
||
* @param root - 根对象
|
||
*/
|
||
const rebuildObjectMap = (root: THREE.Object3D) => {
|
||
if (root.userData.id) {
|
||
objectMap.set(root.userData.id, root)
|
||
}
|
||
root.children.forEach(child => rebuildObjectMap(child))
|
||
}
|
||
|
||
// ============================================================
|
||
// 面包屑生成
|
||
// ============================================================
|
||
|
||
/**
|
||
* 生成面包屑导航数据
|
||
* @param nodeId - 节点ID
|
||
* @returns 面包屑数组
|
||
*/
|
||
const generateBreadcrumbs = (nodeId: string): BreadcrumbItem[] => {
|
||
if (!archetypeModel.value) return []
|
||
|
||
const breadcrumbsList: BreadcrumbItem[] = []
|
||
const nodeIdParts = nodeId.split('~')
|
||
|
||
if (nodeIdParts.length > 0) {
|
||
let currentPath = ''
|
||
|
||
for (let i = 0; i < nodeIdParts.length; i++) {
|
||
const part = nodeIdParts[i]
|
||
currentPath = i === 0 ? part : `${currentPath}~${part}`
|
||
|
||
const currentObject = findNodeById(archetypeModel.value, currentPath)
|
||
const displayText = currentObject
|
||
? currentObject.name
|
||
: part || findNodeById(archetypeModel.value, currentPath)?.name || part
|
||
|
||
const item: BreadcrumbItem = { text: displayText }
|
||
|
||
if (currentPath !== nodeId) {
|
||
item.to = `?currentNodeId=${currentPath}`
|
||
}
|
||
|
||
breadcrumbsList.push(item)
|
||
}
|
||
}
|
||
|
||
// 使用当前节点ID的第一部分作为面包屑首项
|
||
if (breadcrumbsList.length > 0 && store.currentNodeId) {
|
||
const firstPart = store.currentNodeId.split('~')[0]
|
||
breadcrumbsList[0].text = firstPart
|
||
}
|
||
|
||
return breadcrumbsList
|
||
}
|
||
|
||
// ============================================================
|
||
// 相机适配
|
||
// ============================================================
|
||
|
||
/**
|
||
* 适配视图到目标对象
|
||
* @param targetObj - 目标对象(默认为modelGroup)
|
||
* @param keepOrientation - 是否保持当前相机朝向
|
||
*/
|
||
const fitView = (targetObj?: THREE.Object3D, keepOrientation: boolean = true) => {
|
||
const obj = targetObj || modelGroup
|
||
const camera = getCamera()
|
||
const controls = getControls()
|
||
|
||
console.log('[fitView] 开始执行:', {
|
||
hasTargetObj: !!targetObj,
|
||
objName: obj?.name,
|
||
objChildrenCount: obj?.children?.length,
|
||
hasCamera: !!camera,
|
||
hasControls: !!controls,
|
||
})
|
||
|
||
if (!obj || !camera || !controls) {
|
||
console.warn('[fitView] 提前返回: obj/camera/controls 缺失')
|
||
return
|
||
}
|
||
|
||
// 计算包围盒
|
||
const box = new THREE.Box3().setFromObject(obj)
|
||
console.log('[fitView] 包围盒:', {
|
||
isEmpty: box.isEmpty(),
|
||
min: box.min.toArray(),
|
||
max: box.max.toArray(),
|
||
})
|
||
|
||
if (box.isEmpty()) {
|
||
console.warn('[fitView] 提前返回: 包围盒为空')
|
||
return
|
||
}
|
||
|
||
const size = box.getSize(new THREE.Vector3())
|
||
const center = box.getCenter(new THREE.Vector3())
|
||
|
||
// 计算适配距离
|
||
const maxSize = Math.max(size.x, size.y, size.z)
|
||
const fitHeightDistance = maxSize / (2 * Math.atan((Math.PI * camera.fov) / 360))
|
||
const fitWidthDistance = fitHeightDistance / camera.aspect
|
||
const distance = CAMERA_ANIMATION_CONFIG.distanceMultiplier * Math.max(fitHeightDistance, fitWidthDistance)
|
||
|
||
// 计算相机方向
|
||
let direction: THREE.Vector3
|
||
if (keepOrientation) {
|
||
direction = new THREE.Vector3().subVectors(camera.position, controls.target).normalize()
|
||
if (direction.lengthSq() < 0.0001) {
|
||
direction = new THREE.Vector3(1, 1, 1).normalize()
|
||
}
|
||
} else {
|
||
direction = new THREE.Vector3(1, 1, 1).normalize()
|
||
}
|
||
|
||
const targetPosition = center.clone().add(direction.multiplyScalar(distance))
|
||
|
||
console.log('[fitView] 动画参数:', {
|
||
size: size.toArray(),
|
||
center: center.toArray(),
|
||
distance,
|
||
currentCameraPos: camera.position.toArray(),
|
||
targetCameraPos: targetPosition.toArray(),
|
||
})
|
||
|
||
// 相机位置动画
|
||
new TWEEN.Tween(camera.position)
|
||
.to({ x: targetPosition.x, y: targetPosition.y, z: targetPosition.z }, CAMERA_ANIMATION_CONFIG.duration)
|
||
.easing(TWEEN.Easing.Quadratic.InOut)
|
||
.onStart(() => console.log('[fitView] 相机位置动画开始'))
|
||
.onComplete(() => console.log('[fitView] 相机位置动画完成'))
|
||
.start()
|
||
|
||
// 控制目标动画
|
||
new TWEEN.Tween(controls.target)
|
||
.to({ x: center.x, y: center.y, z: center.z }, CAMERA_ANIMATION_CONFIG.duration)
|
||
.easing(TWEEN.Easing.Quadratic.InOut)
|
||
.onStart(() => console.log('[fitView] 控制目标动画开始'))
|
||
.onUpdate(() => {
|
||
controls.update()
|
||
})
|
||
.onComplete(() => console.log('[fitView] 控制目标动画完成'))
|
||
.start()
|
||
}
|
||
|
||
// ============================================================
|
||
// 材质处理
|
||
// ============================================================
|
||
|
||
/**
|
||
* 替换为PBR材质
|
||
* @param object - 要处理的对象
|
||
*/
|
||
const replaceWithPBRMaterial = (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: THREE.MeshStandardMaterial[] = []
|
||
|
||
materials.forEach(mat => {
|
||
const color =
|
||
(mat as THREE.MeshBasicMaterial).color instanceof THREE.Color
|
||
? (mat as THREE.MeshBasicMaterial).color.clone()
|
||
: new THREE.Color(0xaaaaaa)
|
||
|
||
// 获取原始贴图
|
||
const originalMap = (mat as THREE.MeshBasicMaterial).map || null
|
||
const alphaMap = originalMap || null
|
||
const useAlpha = originalMap !== null
|
||
|
||
const pbrMaterial = new THREE.MeshStandardMaterial({
|
||
color: color,
|
||
metalness: PBR_MATERIAL_CONFIG.metalness,
|
||
roughness: PBR_MATERIAL_CONFIG.roughness,
|
||
transparent: useAlpha || ((mat as THREE.MeshBasicMaterial).transparent ?? false),
|
||
opacity: useAlpha ? 1 : ((mat as THREE.MeshBasicMaterial).opacity ?? 1),
|
||
side: mat.side || THREE.FrontSide,
|
||
map: originalMap,
|
||
alphaMap: alphaMap,
|
||
normalMap: (mat as THREE.MeshStandardMaterial).normalMap || null,
|
||
envMapIntensity: PBR_MATERIAL_CONFIG.envMapIntensity,
|
||
})
|
||
|
||
newMaterials.push(pbrMaterial)
|
||
})
|
||
|
||
if (newMaterials.length > 0) {
|
||
mesh.material = (Array.isArray(mesh.material) ? newMaterials : newMaterials[0]) as
|
||
| THREE.Material
|
||
| THREE.Material[]
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 更新材质模式
|
||
* @param root - 根对象
|
||
* @param mode - 光照模式
|
||
*/
|
||
const updateMaterials = (root: THREE.Object3D, mode: LightingMode) => {
|
||
root.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 (mode === 'basic') {
|
||
// 切换到基础模式
|
||
if (mat.type === 'MeshLambertMaterial') return mat
|
||
if (mat.userData.lambertPartner) return mat.userData.lambertPartner
|
||
|
||
const source = mat as THREE.MeshStandardMaterial
|
||
const lambert = new THREE.MeshLambertMaterial({
|
||
color: source.color,
|
||
map: source.map,
|
||
transparent: source.transparent,
|
||
opacity: source.opacity,
|
||
side: source.side,
|
||
alphaMap: source.alphaMap,
|
||
aoMap: source.aoMap,
|
||
})
|
||
|
||
mat.userData.lambertPartner = lambert
|
||
lambert.userData.pbrPartner = mat
|
||
return lambert
|
||
} else {
|
||
// 切换到高级模式
|
||
if (mat.type === 'MeshStandardMaterial') return mat
|
||
if (mat.userData.pbrPartner) return mat.userData.pbrPartner
|
||
return mat
|
||
}
|
||
})
|
||
|
||
mesh.material = Array.isArray(mesh.material) ? newMaterials : newMaterials[0]
|
||
}
|
||
})
|
||
}
|
||
|
||
// ============================================================
|
||
// 光照模式控制
|
||
// ============================================================
|
||
|
||
/**
|
||
* 切换光照模式
|
||
*/
|
||
const toggleLighting = () => {
|
||
lightingMode.value = lightingMode.value === 'basic' ? 'advanced' : 'basic'
|
||
applyLightingMode()
|
||
}
|
||
|
||
/**
|
||
* 应用当前光照模式
|
||
*/
|
||
const applyLightingMode = () => {
|
||
const scene = getScene()
|
||
if (!scene) return
|
||
|
||
if (lightingMode.value === 'advanced') {
|
||
if (envTexture) scene.environment = envTexture
|
||
} else {
|
||
scene.environment = null
|
||
}
|
||
|
||
if (modelGroup) updateMaterials(modelGroup, lightingMode.value)
|
||
if (archetypeModel.value) updateMaterials(archetypeModel.value, lightingMode.value)
|
||
}
|
||
|
||
// ============================================================
|
||
// 模式上下文
|
||
// ============================================================
|
||
|
||
/** 供加载模式使用的共享上下文 */
|
||
const context = {
|
||
store,
|
||
isLoading,
|
||
archetypeModel,
|
||
modelGroup,
|
||
objectMap,
|
||
modelCache,
|
||
replaceWithPBRMaterial,
|
||
generateHierarchy,
|
||
initEnvironment,
|
||
applyLightingMode,
|
||
fitView,
|
||
findNodeById,
|
||
rebuildObjectMap,
|
||
generateBreadcrumbs,
|
||
}
|
||
|
||
// 实例化加载模式
|
||
const { loadModel, processModel } = useFullMode(context)
|
||
const { loadModelByFileName } = useSimplifiedMode(context)
|
||
|
||
// ============================================================
|
||
// 导出接口
|
||
// ============================================================
|
||
|
||
return {
|
||
// 数据
|
||
modelGroup,
|
||
isLoading,
|
||
lightingMode,
|
||
objectMap,
|
||
archetypeModel,
|
||
|
||
// 初始化
|
||
initModelSystem,
|
||
|
||
// 模型加载
|
||
loadModel,
|
||
processModel,
|
||
loadModelByFileName,
|
||
|
||
// 视图控制
|
||
fitView,
|
||
|
||
// 光照控制
|
||
toggleLighting,
|
||
|
||
// 工具函数
|
||
findNodeById,
|
||
generateBreadcrumbs,
|
||
}
|
||
}
|