409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
/**
|
||
* 三维场景交互系统
|
||
* 处理鼠标悬停、点击、双击等交互事件
|
||
* 提供物体高亮和轮廓效果
|
||
*/
|
||
import { navigateTo } from 'nuxt/app'
|
||
import * as THREE from 'three'
|
||
import type { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
|
||
import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js'
|
||
import { useRoute } from 'vue-router'
|
||
import { usePowerStationStore } from '~/stores/powerStation'
|
||
import type { HighlightConfig, RaycasterConfig } from './types'
|
||
|
||
// ============================================================
|
||
// 配置常量
|
||
// ============================================================
|
||
|
||
/** 悬停轮廓颜色 */
|
||
const HOVER_OUTLINE_COLOR = '#435c9d'
|
||
|
||
/** 点击轮廓颜色 */
|
||
const CLICK_OUTLINE_COLOR = '#9d7e43'
|
||
|
||
/** 轮廓通道配置 */
|
||
const OUTLINE_PASS_CONFIG = {
|
||
edgeStrength: 2,
|
||
edgeGlow: 1,
|
||
edgeThickness: 1,
|
||
} as const
|
||
|
||
/** 默认高亮配置 */
|
||
const DEFAULT_HIGHLIGHT_CONFIG: HighlightConfig = {
|
||
color: new THREE.Color(0x00ff00),
|
||
intensity: 0.5,
|
||
duration: 200,
|
||
}
|
||
|
||
/** 默认射线检测配置 */
|
||
const DEFAULT_RAYCASTER_CONFIG: RaycasterConfig = {
|
||
recursive: true,
|
||
threshold: 0.1,
|
||
}
|
||
|
||
// ============================================================
|
||
// 材质管理
|
||
// ============================================================
|
||
|
||
/** 存储原始材质用于恢复 */
|
||
const originalMaterials = new Map<string, THREE.Material | THREE.Material[]>()
|
||
|
||
/**
|
||
* 高亮物体(使用线框材质)
|
||
* @param object - 要高亮的物体
|
||
* @param config - 高亮配置
|
||
*/
|
||
const highlightObject = (
|
||
object: THREE.Object3D,
|
||
config: HighlightConfig = DEFAULT_HIGHLIGHT_CONFIG
|
||
) => {
|
||
object.traverse(child => {
|
||
if (child instanceof THREE.Mesh) {
|
||
const uuid = child.uuid
|
||
|
||
// 存储原始材质
|
||
if (!originalMaterials.has(uuid)) {
|
||
originalMaterials.set(uuid, child.material)
|
||
}
|
||
|
||
// 创建高亮材质
|
||
const highlightMaterial = new THREE.MeshBasicMaterial({
|
||
color: config.color,
|
||
transparent: true,
|
||
opacity: config.intensity,
|
||
wireframe: true,
|
||
})
|
||
|
||
child.material = highlightMaterial
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 取消高亮物体(恢复原始材质)
|
||
* @param object - 要取消高亮的物体
|
||
*/
|
||
const unhighlightObject = (object: THREE.Object3D) => {
|
||
object.traverse(child => {
|
||
if (child instanceof THREE.Mesh) {
|
||
const uuid = child.uuid
|
||
const originalMaterial = originalMaterials.get(uuid)
|
||
|
||
if (originalMaterial) {
|
||
child.material = originalMaterial
|
||
originalMaterials.delete(uuid)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// ============================================================
|
||
// 主交互钩子
|
||
// ============================================================
|
||
|
||
/**
|
||
* 交互系统组合式函数
|
||
* @param getScene - 获取场景
|
||
* @param getCamera - 获取相机
|
||
* @param getComposer - 获取后期处理合成器
|
||
* @param getContainer - 获取容器元素
|
||
* @param modelGroup - 模型组
|
||
* @param getArchetypeModel - 获取原型模型
|
||
*/
|
||
export function useInteraction(
|
||
getScene: () => THREE.Scene,
|
||
getCamera: () => THREE.PerspectiveCamera,
|
||
getComposer: () => EffectComposer,
|
||
getContainer: () => HTMLElement | undefined,
|
||
modelGroup: THREE.Group,
|
||
getArchetypeModel: () => THREE.Object3D | null
|
||
) {
|
||
const store = usePowerStationStore()
|
||
const route = useRoute()
|
||
|
||
// 射线检测器
|
||
const raycaster = new THREE.Raycaster()
|
||
const mouse = new THREE.Vector2()
|
||
|
||
// 轮廓通道
|
||
let hoverOutlinePass: OutlinePass
|
||
let clickOutlinePass: OutlinePass
|
||
|
||
// 当前交互状态
|
||
let currentHoverObject: THREE.Object3D | null = null
|
||
let currentClickObject: THREE.Object3D | null = null
|
||
|
||
// ============================================================
|
||
// 射线检测
|
||
// ============================================================
|
||
|
||
/**
|
||
* 获取鼠标与场景物体的交点
|
||
* @param event - 鼠标事件
|
||
* @returns 交点数组
|
||
*/
|
||
const getIntersects = (event: MouseEvent): THREE.Intersection[] => {
|
||
const container = getContainer()
|
||
const camera = getCamera()
|
||
|
||
if (!container || !camera) return []
|
||
|
||
const rect = container.getBoundingClientRect()
|
||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||
|
||
raycaster.setFromCamera(mouse, camera)
|
||
|
||
return raycaster.intersectObjects(modelGroup.children, true)
|
||
}
|
||
|
||
/**
|
||
* 查找具有用户数据ID的父级物体
|
||
* @param object - 起始物体
|
||
* @returns 具有ID的物体或null
|
||
*/
|
||
const findObjectWithId = (object: THREE.Object3D): THREE.Object3D | null => {
|
||
let target = object
|
||
|
||
while (target && !target.userData.id && target !== modelGroup) {
|
||
if (target.parent) {
|
||
target = target.parent
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
return target?.userData.id ? target : null
|
||
}
|
||
|
||
// ============================================================
|
||
// 高亮效果
|
||
// ============================================================
|
||
|
||
/**
|
||
* 高亮悬停物体
|
||
* @param object - 要高亮的物体(null清除高亮)
|
||
*/
|
||
const highlightHover = (object: THREE.Object3D | null) => {
|
||
if (!hoverOutlinePass) return
|
||
|
||
if (object && object !== currentClickObject) {
|
||
if (currentHoverObject !== object) {
|
||
hoverOutlinePass.selectedObjects = [object]
|
||
currentHoverObject = object
|
||
}
|
||
} else {
|
||
hoverOutlinePass.selectedObjects = []
|
||
currentHoverObject = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 高亮点击物体
|
||
* @param object - 要高亮的物体(null清除高亮)
|
||
*/
|
||
const highlightClick = (object: THREE.Object3D | null) => {
|
||
if (!clickOutlinePass) return
|
||
|
||
if (object) {
|
||
// 清除相同物体的悬停高亮
|
||
if (currentHoverObject === object) {
|
||
hoverOutlinePass.selectedObjects = []
|
||
currentHoverObject = null
|
||
}
|
||
|
||
clickOutlinePass.selectedObjects = [object]
|
||
currentClickObject = object
|
||
} else {
|
||
clickOutlinePass.selectedObjects = []
|
||
currentClickObject = null
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 事件处理
|
||
// ============================================================
|
||
|
||
/**
|
||
* 点击事件处理
|
||
*/
|
||
const onClick = (event: MouseEvent) => {
|
||
const intersects = getIntersects(event)
|
||
|
||
if (intersects.length > 0 && intersects[0]) {
|
||
const target = findObjectWithId(intersects[0].object)
|
||
|
||
if (target && target.userData.id) {
|
||
const id = target.userData.id
|
||
const currentRouteNodeId = route.query.currentNodeId as string
|
||
const shouldHighlight = id !== currentRouteNodeId
|
||
|
||
// 更新store中的选中节点
|
||
store.selectNode({ id, label: target.name || `节点 ${id}` })
|
||
|
||
// 只有ID不一致时才高亮
|
||
if (shouldHighlight) {
|
||
highlightClick(target)
|
||
} else {
|
||
highlightClick(null)
|
||
}
|
||
}
|
||
} else {
|
||
// 点击空白区域,恢复到路由参数值
|
||
const currentRouteNodeId = route.query.currentNodeId as string
|
||
if (currentRouteNodeId) {
|
||
store.selectNode({ id: currentRouteNodeId, label: currentRouteNodeId })
|
||
} else {
|
||
store.selectNode({ id: '' })
|
||
}
|
||
highlightClick(null)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 双击事件处理
|
||
*/
|
||
const onDoubleClick = async (event: MouseEvent) => {
|
||
const intersects = getIntersects(event)
|
||
|
||
if (intersects.length > 0 && intersects[0]) {
|
||
const target = findObjectWithId(intersects[0].object)
|
||
|
||
if (target && target.userData.id) {
|
||
const id = target.userData.id
|
||
await navigateTo({
|
||
query: {
|
||
...route.query,
|
||
currentNodeId: id,
|
||
},
|
||
})
|
||
}
|
||
} else {
|
||
// 双击空白区域,返回父级
|
||
await handleNavigateToParent()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 导航到父级节点
|
||
*/
|
||
const handleNavigateToParent = async () => {
|
||
const currentRouteNodeId = route.query.currentNodeId as string
|
||
|
||
// 如果是根节点,不操作
|
||
if (!currentRouteNodeId || !currentRouteNodeId.includes('~')) {
|
||
return
|
||
}
|
||
|
||
// 删除最后一个"~"之后的内容
|
||
const lastUnderlineIndex = currentRouteNodeId.lastIndexOf('~')
|
||
if (lastUnderlineIndex > 0) {
|
||
const newCurrentNodeId = currentRouteNodeId.substring(0, lastUnderlineIndex)
|
||
|
||
await navigateTo({
|
||
query: {
|
||
...route.query,
|
||
currentNodeId: newCurrentNodeId,
|
||
},
|
||
})
|
||
|
||
store.selectNode({ id: newCurrentNodeId, label: newCurrentNodeId })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 鼠标移动事件处理
|
||
*/
|
||
const onMouseMove = (event: MouseEvent) => {
|
||
const intersects = getIntersects(event)
|
||
|
||
if (intersects.length > 0 && intersects[0]) {
|
||
const target = findObjectWithId(intersects[0].object)
|
||
highlightHover(target)
|
||
} else {
|
||
highlightHover(null)
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 初始化与清理
|
||
// ============================================================
|
||
|
||
/**
|
||
* 初始化交互系统
|
||
*/
|
||
const initInteraction = () => {
|
||
const scene = getScene()
|
||
const camera = getCamera()
|
||
const composer = getComposer()
|
||
const container = getContainer()
|
||
|
||
if (!scene || !camera || !composer || !container) return
|
||
|
||
const width = container.clientWidth
|
||
const height = container.clientHeight
|
||
|
||
// 创建悬停轮廓通道
|
||
hoverOutlinePass = new OutlinePass(new THREE.Vector2(width, height), scene, camera)
|
||
hoverOutlinePass.edgeStrength = OUTLINE_PASS_CONFIG.edgeStrength
|
||
hoverOutlinePass.edgeGlow = OUTLINE_PASS_CONFIG.edgeGlow
|
||
hoverOutlinePass.edgeThickness = OUTLINE_PASS_CONFIG.edgeThickness
|
||
hoverOutlinePass.visibleEdgeColor.set(HOVER_OUTLINE_COLOR)
|
||
hoverOutlinePass.hiddenEdgeColor.set(HOVER_OUTLINE_COLOR)
|
||
composer.addPass(hoverOutlinePass)
|
||
|
||
// 创建点击轮廓通道
|
||
clickOutlinePass = new OutlinePass(new THREE.Vector2(width, height), scene, camera)
|
||
clickOutlinePass.edgeStrength = OUTLINE_PASS_CONFIG.edgeStrength
|
||
clickOutlinePass.edgeGlow = OUTLINE_PASS_CONFIG.edgeGlow
|
||
clickOutlinePass.edgeThickness = OUTLINE_PASS_CONFIG.edgeThickness
|
||
clickOutlinePass.visibleEdgeColor.set(CLICK_OUTLINE_COLOR)
|
||
clickOutlinePass.hiddenEdgeColor.set(CLICK_OUTLINE_COLOR)
|
||
composer.addPass(clickOutlinePass)
|
||
|
||
// 绑定事件
|
||
container.addEventListener('click', onClick)
|
||
container.addEventListener('dblclick', onDoubleClick)
|
||
container.addEventListener('mousemove', onMouseMove)
|
||
}
|
||
|
||
/**
|
||
* 更新轮廓通道尺寸
|
||
* @param width - 新宽度
|
||
* @param height - 新高度
|
||
*/
|
||
const updatePassSize = (width: number, height: number) => {
|
||
if (hoverOutlinePass) hoverOutlinePass.setSize(width, height)
|
||
if (clickOutlinePass) clickOutlinePass.setSize(width, height)
|
||
}
|
||
|
||
/**
|
||
* 清理资源
|
||
*/
|
||
const dispose = () => {
|
||
const container = getContainer()
|
||
if (container) {
|
||
container.removeEventListener('click', onClick)
|
||
container.removeEventListener('dblclick', onDoubleClick)
|
||
container.removeEventListener('mousemove', onMouseMove)
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 导出接口
|
||
// ============================================================
|
||
|
||
return {
|
||
/** 初始化交互系统 */
|
||
initInteraction,
|
||
/** 高亮点击物体 */
|
||
highlightClick,
|
||
/** 高亮悬停物体 */
|
||
highlightHover,
|
||
/** 更新轮廓通道尺寸 */
|
||
updatePassSize,
|
||
/** 清理资源 */
|
||
dispose,
|
||
}
|
||
}
|