ThreeJS 3D看板项目

740 阅读19分钟

ThreeJS 3D看板项目

1、项目简介

1.1 项目概述

  • 这是一个专注于Web3D可视化实战开发的教学项目,以工厂设备场景为核心案例,展示如何将现代前端框架与3D渲染技术结合,构建真实的商业级3D可视化应用。
  • Vue3(采用组合式API风格)+ Vite构建工具 + Three.js。
  • 项目面向智慧工厂、园区、水利水电、物流、农业等多种3D可视化应用场景。

1.2 效果预览

描述:

  1. 3d场景中展示设备信息,设备名字颜色代表设备运行状态
  2. 自定义动画,自动循环展示每一个设备的具体信息
  3. 点击设备展示设备具体信息、并暂停相关动画

效果如下: 3d.gif

2、初始化

简单看一下布局,equipmentKK 为父元素、container 为实际场景、btns 为自定义按钮,你也可以自定义其它内容,例如标题,整体统计图表等。

因为没有做拆分,代码太多了,只贴了关键参数,都有具体注释。

<template>
  <div class="equipmentKK">
    <div id="container" ref="container"></div>
    <div class="btns">
        <button @click="updateData">模拟更新数据</button>
    </div>
  </div>
</template>

<script lang="ts" setup>
const stats: any = new Stats() // 性能监控
const gltfloader = new GLTFLoader() // 模型加载器
let scene: any // 场景
let camera: any // 相机
let renderer: any // 渲染器
let css2DRenderer: any// 2D渲染器
let composer: any // 效果合成器
let controls: any // 控制器
let canvasWidth: any // 画布宽度
let canvasHeight: any // 画布高度
let container: any = ref(null) // 容器
let rect: any // rect 变量的作用是获取容器元素相对于视口的位置信息,主要用于射线投射(Raycasting)中的鼠标坐标转换
let animationFrameID: any // 动画帧ID
let tween: any // 动画1
let tween1: any // 动画2

const textureLoader = new THREE.TextureLoader() // 纹理加载器
let boardMesh: any = null // 新增:3D看板Mesh
const myTag = '3D_KanBan' // 看板标签

// 自动动画定时器 记录设备模型 
const deviceMeshes: any = []
let boardStartIndex = 0 // 3D 看板元素起始序号
let kanban3DTimer: any = null // 3D看板定时器
const durationKanban = 5 // 3D看板动画时长
let animateTimer: any // 动画定时器
let isExecution = true // 是否执行动画
let isAnimating = false // 标记动画开始

// 设备数组
let deviceArr = [
  { name: 'JK1', position: [-10, 0.01, -40], total: 100, now: 50, status: 1 },
]
<script>

在 onMounted 中执行初始化函数,先获取到画布的宽度和高度,初始化时相机等会用到,然后就是获取到位置信息 rect 后面点击元素时会用到。
因为耦合度太高了,下面这几个函数除 importModel() 外,必须一起都执行才能创建一个完整场景。不建议没有Three.js基础的开发者直接上手项目,不过每个函数具体功能是啥都会具体介绍,结合ai看懂也没啥大问题。

// 初始化
const init = () => {
  canvasWidth = container.value.clientWidth
  canvasHeight = container.value.clientHeight
  rect = container.value.getBoundingClientRect()
  initScene()
  initOther()
  initLight()
  initCamera()
  initRenderer(container.value)
  initOrbitControls()
  animation()
  importModel()
}

下面逐一介绍每一个函数的具体作用:

2.1 初始化场景

执行如下函数会得到一个黑色的场景,此时,场景中没有任何模型。

// 场景
const initScene = () => {
  // 初始化一个黑色的场景
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x000000); // 设置背景为黑色
}

2.2 添加地面、墙壁等

地面和墙壁都是直接添加了一个长方形,然后给长方形贴上贴图模拟地面和墙壁。这里用的贴图是 1:1 的,为了保证贴图不变形,可以使用 texture.repeat.set(5, 10) 让贴图在横向重复5次,纵向重复10次,这个比例和长方形的比例保持一致即可,即 new THREE.PlaneGeometry(50, 100)texture.repeat.set(5, 10) 的比例一致。 坐标轴辅助可以选择性开启,方便布局。

// 地面 坐标轴 辅助
const initOther = () => {
  setTheGround()
  setTheWall()
  // 坐标轴
  // const axesHelper = new THREE.AxesHelper(50)
  // scene.add(axesHelper)
  container.value.appendChild(stats.domElement) // 添加性能监控
}
// 设置地面
const setTheGround = () => {
  // 创建纹理
  const texture = textureLoader.load(floorTextureSrc)
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(5, 10) // 横向重复5次,纵向重复10次

  // 添加地面
  const floorMesh = new THREE.Mesh(
    new THREE.PlaneGeometry(50, 100),
    new THREE.MeshPhongMaterial({
      side: THREE.DoubleSide,
      map: texture,
    }));
  floorMesh.name = 'floor'
  floorMesh.receiveShadow = true;
  floorMesh.rotation.x = - Math.PI / 2.0;
  scene.add(floorMesh);
}
// 设置墙面
const setTheWall = () => {
  // 创建纹理
  const texture = textureLoader.load(wallTextureSrc)
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(10, 1) // 横向重复5次,纵向重复10次

  // 添加墙壁1
  const wallMesh = new THREE.Mesh(
    new THREE.PlaneGeometry(100, 10),
    new THREE.MeshPhongMaterial({
      side: THREE.DoubleSide,
      map: texture,
    }));
  wallMesh.name = 'wall'
  wallMesh.receiveShadow = true;
  wallMesh.rotation.y = - Math.PI / 2.0;
  wallMesh.position.y = 5
  wallMesh.position.x = 25
  // 克隆wallMesh
  const wallMesh1 = wallMesh.clone()
  const wallMesh2 = wallMesh.clone()
  wallMesh2.position.x = -25
  scene.add(wallMesh1);
  scene.add(wallMesh2);
}

2.3 添加灯光

同时添加了环境光和点光源,模拟更逼真的环境效果。

// 添加灯光
const initLight = () => {
  const light = new THREE.AmbientLight(0x404040, 20); // 柔和的白光
  scene.add(light);
  // 点光源
  const pointLight = new THREE.PointLight(0xffffff, 100, 100);
  pointLight.position.set(0, 20, 0);
  pointLight.castShadow = true
  scene.add(pointLight);
}

2.4 添加相机

创建透视相机,并设置初始化位置。PerspectiveCamera 具体参数作用如下:

image.png
// 相机
const initCamera = () => {
  camera = new THREE.PerspectiveCamera(75, canvasWidth / canvasHeight, 0.1, 1000)
  camera.position.set(0, 20, 70);
}

2.5 渲染器

  • WebGLRenderer:使用WebGL技术进行硬件加速渲染,性能最佳

  • antialias: true:开启抗锯齿,让3D物体的边缘更加平滑,减少锯齿感

    • 优点:画面质量更好,边缘更平滑
    • 缺点:会消耗更多GPU性能
// 渲染器
const initRenderer = (el: any) => {
  renderer = new THREE.WebGLRenderer({
    antialias: true
  });
  // renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setPixelRatio(canvasWidth / canvasHeight) //设置像素比例
  renderer.setSize(canvasWidth, canvasHeight); // 设置渲染尺寸
  el.appendChild(renderer.domElement); // 添加到DOM
}

2.6 轨道控制器

new OrbitControls(camera, renderer.domElement)

  • 第一个参数:绑定要控制的相机对象
  • 第二个参数:绑定监听鼠标事件的DOM元素(canvas画布)
  • 作用:让用户可以通过鼠标操作来控制相机的位置和角度
// 轨道控制器
const initOrbitControls = () => {
  controls = new OrbitControls(camera, renderer.domElement)
  controls.maxDistance = 150; // 设置距离限制
  controls.addEventListener('change', render)
  controls.enablePan = true; // 启用平移
  controls.enableDamping = true // 开启阻尼效果
  controls.dampingFactor = 0.02 // 阻尼系数
  // 自动旋转
  // controls.autoRotate = true
  // controls.autoRotateSpeed = 1
}

2.7 渲染动画帧

这个函数的作用是创建渲染循环,让3D场景持续刷新和更新,是整个3D应用的"心脏"。

  • requestAnimationFrame:浏览器提供的动画API,通常以60FPS的频率执行
  • 递归调用:函数调用自身,创建无限循环
  • animationFrameID:保存动画帧ID,用于后续取消动画循环
  • 优势:与浏览器刷新率同步,页面不可见时自动暂停,节省性能
// 动画帧
const animation = () => {
  animationFrameID = requestAnimationFrame(animation)
  controls.update()
  // 让3D看板始终朝向摄像头
  if (boardMesh) {
    // boardMesh.lookAt(camera.position)
  }
  render()
}

// 渲染 
const render = () => {
  stats.update()
  renderer.render(scene, camera);
  if (composer) {
    composer.render();
  }
}

3、模型导入

  • 因为没有接api接口,现在是在初始化的时候就直接导入模型。导入模型并没有和其它函数耦合,在任何地方导入都可以,你可以等获取到位置信息等数据后在导入模型。测试案例用的是glb格式、其它格式的可自行查询如何解析并导入。
  • 模型是放在 src/assets 下面的,并没有放到 public 静态目录下,(直接放public后能直接导入,但是打包后就会出现路径错误等问题,没找到有效解决办法,所以用 import 导入,打包后正常),没有做额外配置可能会出现如下报错:
image.png
  • vite.config.ts 中添加如下配置解析 glb 格式:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// @ts-ignore
import path from 'path'

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve('./src'), // 相对路径别名配置,使用 @ 代替 src
    },
  },
  // 解决glb和gltf文件打包后无法加载的问题
  assetsInclude: ['**/*.glb', '**/*.gltf'],
})

模型导入函数如下:

  1. 模型导入成功后立即执行了自定义动画,然后在用定时器循环执行自定义动画。
  2. 给每一个模型都添加了自定义属性 myTag,方便后期点击时做判断,当然,你也可以直接自定义模型的name,以name为判断条件也是可以的,不过,name 另有用处,还是建议使用自定义属性。
  3. 注意事项:Model1.clone() 模型克隆只会克隆原始属性,自定义属性不会被克隆,因此,添加自定义属性需要在克隆之后在添加。
const importModel = async () => {
  gltfloader.load(wittgenstein, (loader: any) => {
    let Model1 = loader.scene
    for (let i = 0; i < deviceArr.length; i++) {
      let device = deviceArr[i]
      let Model2 = Model1.clone()
      Model2.position.set(device.position[0], device.position[1], device.position[2])
      Model2.name = device.name
      Model2.myTag = myTag
      Model2.status = device.status
      addText(Model2)
      deviceMeshes.push(Model2)
      scene.add(Model2)
    }
    executeKanban()
    // 设置定时器
    kanban3DTimer = setInterval(executeKanban, durationKanban * 1000);
  })
}

4、给模型添加名字

  1. 传入需要添加名字的模型
  2. 通过 canvas 创建文字贴图
  3. 创建平面几何体、把创建好的canvas贴图贴到平面几何体上面
  4. 把平面几何体放到合适的位置即可, textMesh.position.y = 4,这里是默认写死了,你可以修改根据模型的高度自动调整。
  5. 模型导入时如果有需要即可调用该函数为模型添加名字,默认名字为模型的 name 属性,你也可以自定义。
// 为指定物体添加文字标注
const addText = (obj: any) => {
  // 1. 创建canvas
  const canvas = document.createElement('canvas')
  canvas.width = 256
  canvas.height = 64
  const ctx = canvas.getContext('2d')!
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.font = 'bold 32px Microsoft YaHei'
  ctx.fillStyle = obj.status == 0 ? 'green' : 'red'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(obj.name, canvas.width / 2, canvas.height / 2)

  // 2. 创建贴图
  const texture = new THREE.CanvasTexture(canvas)
  texture.needsUpdate = true

  // 3. 创建平面几何体
  const aspect = canvas.width / canvas.height
  const geometry = new THREE.PlaneGeometry(2 * aspect, 2)
  const material = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    side: THREE.DoubleSide
  })
  const textMesh = new THREE.Mesh(geometry, material)
  textMesh.position.copy(obj.position)
  textMesh.position.y = 4
  textMesh.myTag = 'text'
  textMesh.fatherTag = obj.name
  scene.add(textMesh)
}

5、创建3D看板

  1. 说是3D看板,实际上还是平面几何体,和 4、给模型添加文字 实现一模一样,不同点在于支持多行文字,你可以在这里实现自己的看板。
  2. 这是根据名字从设备数组(数据)中拿到模型的基本信息(位置信息等)

为什么这里我不传模型进来,直接通过模型获取位置信息呢?

  • 主要是考虑到数据的动态变化,实时通信或者用定时器每隔一段时间拉取一次最新的数据。
  • 一般情况下模型导入设置好位置后就不需要再次导入了,一些需要展示的数据如果你都挂在模型上通过模型来展示数据的话,每次更新数据你都要更新模型上面的所有数据。
  • 通过名称去设备数组中查找,就完全没必要关注模型了,我们只需要关心数据即可。
// 新增:创建3D看板的函数
const create3DBoard = (text: string) => {
  // 从设备数组中找到当前设备的名称
  const device = deviceArr.find((el: any) => el.name === text);
  // 创建canvas
  const canvas = document.createElement('canvas')
  canvas.width = 316
  canvas.height = 158
  const ctx = canvas.getContext('2d')!
  ctx.fillStyle = device?.status == 0 ? 'rgba(32, 147, 246,0.5)' : 'rgba(255, 0, 0,0.5)'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  ctx.font = 'bold 20px Microsoft YaHei'
  ctx.fillStyle = '#fff'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'

  // 支持多行文本
  const lines = [
    text,
    '设备状态:' + ((device?.status == 0) ? '运行中' : '关闭'),
    '达成率:' + ((device!.now / device!.total) * 100).toFixed(2) + '%',
    '当前报工数:' + device?.now,
    '总报工数:' + device?.total,
  ]
  const lineHeight = 30
  const startY = 20
  lines.forEach((line, i) => {
    ctx.fillText(line, canvas.width / 2, startY + i * lineHeight)
  })

  // 贴图
  const texture = new THREE.CanvasTexture(canvas)
  const material = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    side: THREE.DoubleSide // 关键:双面可见
  })
  // 需保持和canvas宽高比一致,否则会出现拉伸或压缩
  const geometry = new THREE.PlaneGeometry(10, 5)
  const mesh = new THREE.Mesh(geometry, material)
  mesh.name = '3dBoard'
  mesh.renderOrder = 999 // 保证在前面
  // mesh.lookAt(camera.position) // 朝向相机
  mesh.scale.set(0, 0, 0) // 初始缩放为0
  return mesh
}

6、点击事件、动画

6.1 点击事件

感觉没有 ai 讲的的好,就直接给 ai 帮忙分析整个点击过程,只在关键的地方做额外描述。

6.1.1 事件绑定与触发

image.png

6.1.2 射线投射检测

image.png 射线投射原理:

  • 将鼠标2D坐标转换为3D世界坐标
  • 从相机位置发射一条射线穿过鼠标点击位置
  • 检测射线与场景中哪些物体相交

射线投射函数:

// 射线拾取
const raycaster = (event: any, geometrys: any) => {
  const x = ((event.clientX - rect.left) / canvasWidth) * 2 - 1;
  const y = -((event.clientY - rect.top) / canvasHeight) * 2 + 1;
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
  return raycaster.intersectObjects(geometrys);
}
6.1.3 清理旧状态

包括包围盒和旧的3D看板

// 移除旧的包围盒
const findBox3 = scene.children.find((el: any) => el.name == 'surroundingBox')
if (findBox3) scene.remove(findBox3)

// 移除旧的3D看板
if (boardMesh) {
  scene.remove(boardMesh)
  boardMesh.geometry.dispose()
  boardMesh.material.map.dispose()
  boardMesh.material.dispose()
  boardMesh = null
}

6.1.4 查找目标设备模型
const model = findParentWithName(material[0].object)

查找逻辑:

  • 由于模型可能是复合对象,需要向上查找父级元素
  • 通过 myTag 属性识别是否为设备模型
  • 确保点击到的是完整的设备对象

查找函数:

// 查询点击元素的父元素
const findParentWithName = (object: any) => {
  let parent = object.parent;
  while (parent) {
    // 确保myTag属性存在且匹配
    if (typeof parent.myTag !== 'undefined' && parent.myTag === myTag) {
      // 找到匹配的父元素
      return parent;
    }
    parent = parent.parent;
  }
  // 没有找到匹配的父元素
  return null;
}
6.1.5 点击到设备的处理逻辑
6.1.5.1 停止自动动画
isExecution = false // 停止自动播放
if (tween) { tween.kill(); tween = null; } // 停止相机动画
if (tween1) { tween1.kill(); tween1 = null; }
controls.autoRotate = false // 停止自动旋转

6.1.5.2 创建3D看板
boardMesh = create3DBoard(model.name) // 创建包含设备信息的3D看板
boardMesh.position.copy(location)
boardMesh.position.y += 8 // 放到模型上方
scene.add(boardMesh)

6.1.5.3 添加视觉效果

通过 gsap 动画库实现看板从 0 到 1 的放大效果。创建3d看板的时候缩放已经设置为 0 了,这里只需要把缩放设置为 1 即可实现 缩放视觉效果。

// 看板缩放动画
gsap.to(boardMesh.scale, {
  x: 1, y: 1, z: 1,
  duration: 0.5,
  ease: "back.out"
})

// 添加包围盒边框
calculateBoundingBox(model)

包围盒就是一个根据模型大小和位置信息,生成一个只有边框的立方体,模拟选中效果,这样做性能会好很多,如果让选中的物体发光变亮的话页面卡的不行,如需体验发光效果,取消第 266 行的注释即可。

image.png

6.1.6 点击到空白区域的处理

image.png

6.2 自定义动画

自定义动画的逻辑是依次展示各设备的生产状态,通过3d看板展示(创建3D看板如下 4.2 ),当用户点击了设备查看时,暂停动画,用户离开后继续执行动画。isExecution 参数就是判断动画是否需要执行,当用户点击了物体就不需执行。

// 使用 async/await
const autoKanban = async (model: any) => {
  const cameraLocation = model.position

  // 先移除旧的内容
  const findBox3 = scene.children.find((el: any) => el.name == 'surroundingBox')
  if (findBox3) scene.remove(findBox3)

  // 移除旧的3D看板
  if (boardMesh) {
    scene.remove(boardMesh)
    boardMesh.geometry.dispose()
    boardMesh.material.map.dispose()
    boardMesh.material.dispose()
    boardMesh = null
  }

  const location = model.position

  // 等待相机动画完成
  await translateCamera(
    new THREE.Vector3(cameraLocation.x, cameraLocation.y + 5, cameraLocation.z + 20),
    new THREE.Vector3(cameraLocation.x, cameraLocation.y, cameraLocation.z)
  )

  // 相机动画完成后创建3D看板
  boardMesh = create3DBoard(model.name)
  boardMesh.position.copy(location)
  boardMesh.position.y += 8
  scene.add(boardMesh)

  // 添加看板的缩放动画
  gsap.to(boardMesh.scale, {
    x: 1,
    y: 1,
    z: 1,
    duration: 0.5,
    ease: "back.out"
  })

  // 选中物体添加边框
  calculateBoundingBox(model)
}

// 修改定时器逻辑
const executeKanban = async () => {
  if (!isExecution || isAnimating) return // 如果正在执行动画则跳过
  isAnimating = true // 标记开始动画
  await autoKanban(deviceMeshes[boardStartIndex])
  if (boardStartIndex < deviceArr.length - 1) {
    boardStartIndex++
  } else {
    boardStartIndex = 0
  }
  isAnimating = false // 标记动画完成
}

7、内存释放(模型清理)

// 内存释放 
const cleanupThreeJS = () => {
  // 停止渲染循环(如果有的话) animationFrameID是requestAnimationFrame的返回值 
  cancelAnimationFrame(animationFrameID)

  // 遍历并清理场景中的所有物体  
  scene.traverse((child: any) => {
    if (child instanceof THREE.Mesh) {
      if (child.geometry?.dispose) child.geometry.dispose()
      if (child.material?.dispose) child.material.dispose()
      if (child.material?.texture?.dispose) child.material.texture.dispose()
    }
    if (child instanceof THREE.Group) child.clear()
    if (child instanceof THREE.Object3D) child.clear()
  })
  renderer.dispose() // 如果你的renderer对象有dispose方法的话  
}

8、完整代码

模型大家自己找一个免费替换即可,贴图任意图片都可以。没做拆分!!!参数在各函数数之间调用很频繁,有兴趣可自行模块化。

<template>
  <div class="equipmentKK">
    <div id="container" ref="container"></div>
    <div class="btns">
      <button @click="updateData">模拟更新数据</button>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { onMounted, onBeforeUnmount, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import floorTextureSrc from '@/assets/images/floot.png'
import wallTextureSrc from '@/assets/images/wall.png'
import Stats from 'three/addons/libs/stats.module.js'
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
import gsap from "gsap"
import wittgenstein from "@/assets/model/wittgenstein.glb"

const stats: any = new Stats() // 性能监控
const gltfloader = new GLTFLoader() // 模型加载器
let scene: any // 场景
let camera: any // 相机
let renderer: any // 渲染器
let composer: any // 效果合成器
let controls: any // 控制器
let canvasWidth: any // 画布宽度
let canvasHeight: any // 画布高度
let container: any = ref(null) // 容器
let rect: any // rect 变量的作用是获取容器元素相对于视口的位置信息,主要用于射线投射(Raycasting)中的鼠标坐标转换
let animationFrameID: any // 动画帧ID
let tween: any // 动画1
let tween1: any // 动画2

const textureLoader = new THREE.TextureLoader() // 纹理加载器
let boardMesh: any = null // 新增:3D看板Mesh
const myTag = '3D_KanBan' // 看板标签

// 自动动画定时器 记录设备模型 
const deviceMeshes: any = []
let boardStartIndex = 0 // 3D 看板元素起始序号
let kanban3DTimer: any = null // 3D看板定时器
const durationKanban = 5 // 3D看板动画时长
let animateTimer: any // 动画定时器
let isExecution = true // 是否执行动画
let isAnimating = false // 标记动画开始

// 设备数组
let deviceArr = [
  { name: 'JK1', position: [-10, 0.01, -40], total: 100, now: 50, status: 1 },
  { name: 'JK2', position: [-10, 0.01, -20], total: 250, now: 30, status: 0 },
  { name: 'JK3', position: [-10, 0.01, 0], total: 130, now: 90, status: 1 },
  { name: 'JK4', position: [-10, 0.01, 20], total: 1000, now: 50, status: 0 },
  { name: 'JK5', position: [-10, 0.01, 40], total: 500, now: 500, status: 1 },
  { name: 'TK5', position: [10, 0.01, 40], total: 200, now: 20, status: 0 },
  { name: 'TK4', position: [10, 0.01, 20], total: 75, now: 55, status: 1 },
  { name: 'TK3', position: [10, 0.01, 0], total: 80, now: 36, status: 1 },
  { name: 'TK2', position: [10, 0.01, -20], total: 60, now: 28, status: 0 },
  { name: 'TK1', position: [10, 0.01, -40], total: 100, now: 10, status: 1 },
]

onMounted(() => {
  init()
  container.value.addEventListener('click', clickFn)
  // window.addEventListener('beforeunload', handleBeforeUnload)
  window.addEventListener('resize', handleResize) // 监听窗口尺寸变化
  container.value.addEventListener('mousedown', handleMouseDown)

})

onBeforeUnmount(() => {
  // label.element.style.visibility = 'hidden';
  cleanupThreeJS()
  container.value.removeEventListener('click', clickFn)
  // window.removeEventListener('beforeunload', handleBeforeUnload)
  window.removeEventListener('resize', handleResize) // 移除监听
  container.value.removeEventListener('mousedown', handleMouseDown)
  // 滚轮滚动也打断 动画
  window.removeEventListener('wheel', handleMouseDown)
  if (animateTimer) clearInterval(animateTimer)
  if (kanban3DTimer) clearInterval(kanban3DTimer)
})

// const handleBeforeUnload = (e: any) => {
//     e.preventDefault()
//     e.returnValue = ""
//     cleanupThreeJS()
// }

// 内存释放 
const cleanupThreeJS = () => {
  // 停止渲染循环(如果有的话) animationFrameID是requestAnimationFrame的返回值 
  cancelAnimationFrame(animationFrameID)

  // 遍历并清理场景中的所有物体  
  scene.traverse((child: any) => {
    if (child instanceof THREE.Mesh) {
      if (child.geometry?.dispose) child.geometry.dispose()
      if (child.material?.dispose) child.material.dispose()
      if (child.material?.texture?.dispose) child.material.texture.dispose()
    }
    if (child instanceof THREE.Group) child.clear()
    if (child instanceof THREE.Object3D) child.clear()
  })
  renderer.dispose() // 如果你的renderer对象有dispose方法的话  
}

// 初始化
const init = () => {
  canvasWidth = container.value.clientWidth
  canvasHeight = container.value.clientHeight
  rect = container.value.getBoundingClientRect()
  initScene()
  initOther()
  initLight()
  initCamera()
  initRenderer(container.value)
  initOrbitControls()
  animation()
  importModel()
}

// 场景
const initScene = () => {
  // 初始化一个黑色的场景
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x000000); // 设置背景为黑色
}

// 相机
const initCamera = () => {
  camera = new THREE.PerspectiveCamera(75, canvasWidth / canvasHeight, 0.1, 1000)
  camera.position.set(0, 20, 70);
}

// 渲染器
const initRenderer = (el: any) => {
  renderer = new THREE.WebGLRenderer({
    antialias: true
  });
  // renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setPixelRatio(canvasWidth / canvasHeight) //设置像素比例
  renderer.setSize(canvasWidth, canvasHeight); // 设置渲染尺寸
  el.appendChild(renderer.domElement); // 添加到DOM
}

// 导入模型
const importModel = async () => {
  gltfloader.load(wittgenstein, (loader: any) => {
    let Model1 = loader.scene
    for (let i = 0; i < deviceArr.length; i++) {
      let device = deviceArr[i]
      let Model2 = Model1.clone()
      Model2.position.set(device.position[0], device.position[1], device.position[2])
      Model2.name = device.name
      Model2.myTag = myTag
      Model2.status = device.status
      addText(Model2)
      deviceMeshes.push(Model2)
      scene.add(Model2)
    }
    executeKanban()
    // 设置定时器
    kanban3DTimer = setInterval(executeKanban, durationKanban * 1000);
  })
}

// 添加灯光
const initLight = () => {
  const light = new THREE.AmbientLight(0x404040, 20); // 柔和的白光
  scene.add(light);
  // 点光源
  const pointLight = new THREE.PointLight(0xffffff, 100, 100);
  pointLight.position.set(0, 20, 0);
  pointLight.castShadow = true
  scene.add(pointLight);
}

// 轨道控制器
const initOrbitControls = () => {
  controls = new OrbitControls(camera, renderer.domElement)
  controls.maxDistance = 150; // 设置距离限制
  controls.addEventListener('change', render)
  controls.enablePan = true; // 启用平移
  controls.enableDamping = true // 开启阻尼效果
  controls.dampingFactor = 0.02 // 阻尼系数
  // 自动旋转
  // controls.autoRotate = true
  // controls.autoRotateSpeed = 1
}


// 渲染 
const render = () => {
  stats.update()
  renderer.render(scene, camera);
  if (composer) {
    composer.render();
  }
}

// 动画帧
const animation = () => {
  animationFrameID = requestAnimationFrame(animation)
  controls.update()
  // 让3D看板始终朝向摄像头
  if (boardMesh) {
    // boardMesh.lookAt(camera.position)
  }
  render()
}

// 查询点击元素的父元素
const findParentWithName = (object: any) => {
  let parent = object.parent;
  while (parent) {
    // 确保myTag属性存在且匹配
    if (typeof parent.myTag !== 'undefined' && parent.myTag === myTag) {
      // 找到匹配的父元素
      return parent;
    }
    parent = parent.parent;
  }
  // 没有找到匹配的父元素
  return null;
}

// 点击事件
const clickFn = (event: any) => {
  event.preventDefault()

  rect = container.value.getBoundingClientRect()
  let material = raycaster(event, scene.children)
  if (material.length <= 0) {
    return;
  }
  const findBox3 = scene.children.find((el: any) => el.name == 'surroundingBox')
  if (findBox3) scene.remove(findBox3)

  // 移除旧的3D看板
  if (boardMesh) {
    scene.remove(boardMesh)
    boardMesh.geometry.dispose()
    boardMesh.material.map.dispose()
    boardMesh.material.dispose()
    boardMesh = null
  }

  const model = findParentWithName(material[0].object)
  if (model) {
    // 打断正在执行的gasp动画
    isExecution = false
    if (tween) {
      tween.kill();
      tween = null;
    }
    if (tween1) {
      tween1.kill();
      tween1 = null;
    }
    // 选中物体发光
    // postRenderer(model)
    controls.autoRotate = false
    const location = model.position

    // 新增:创建3D看板
    boardMesh = create3DBoard(model.name)
    boardMesh.position.copy(location)
    boardMesh.position.y += 8 // 放到模型上方
    scene.add(boardMesh)
    // 添加看板的缩放动画
    gsap.to(boardMesh.scale, {
      x: 1,
      y: 1,
      z: 1,
      duration: 0.5,
      ease: "back.out"
    })
    // 选中物体添加边框
    calculateBoundingBox(model)
    translateCamera(
      new THREE.Vector3(location.x, location.y + 10, location.z + 20),
      new THREE.Vector3(location.x, location.y, location.z)
    )
  } else {
    postRenderer(false)
    // label.element.style.visibility = 'hidden'; // 注释掉2D看板
    // 移除3D看板
    if (boardMesh) {
      scene.remove(boardMesh)
      boardMesh.geometry.dispose()
      boardMesh.material.map.dispose()
      boardMesh.material.dispose()
      boardMesh = null
    }
    isExecution = true
    // controls.autoRotate = true
  }
}

// 新增:创建3D看板的函数
const create3DBoard = (text: string) => {
  // 从设备数组中找到当前设备的名称
  const device = deviceArr.find((el: any) => el.name === text);
  // 创建canvas
  const canvas = document.createElement('canvas')
  canvas.width = 316
  canvas.height = 158
  const ctx = canvas.getContext('2d')!
  ctx.fillStyle = device?.status == 0 ? 'rgba(32, 147, 246,0.5)' : 'rgba(255, 0, 0,0.5)'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  ctx.font = 'bold 20px Microsoft YaHei'
  ctx.fillStyle = '#fff'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'

  // 支持多行文本
  const lines = [
    text,
    '设备状态:' + ((device?.status == 0) ? '运行中' : '关闭'),
    '达成率:' + ((device!.now / device!.total) * 100).toFixed(2) + '%',
    '当前报工数:' + device?.now,
    '总报工数:' + device?.total,
  ]
  const lineHeight = 30
  const startY = 20
  lines.forEach((line, i) => {
    ctx.fillText(line, canvas.width / 2, startY + i * lineHeight)
  })

  // 贴图
  const texture = new THREE.CanvasTexture(canvas)
  const material = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    side: THREE.DoubleSide // 关键:双面可见
  })
  // 需保持和canvas宽高比一致,否则会出现拉伸或压缩
  const geometry = new THREE.PlaneGeometry(10, 5)
  const mesh = new THREE.Mesh(geometry, material)
  mesh.name = '3dBoard'
  mesh.renderOrder = 999 // 保证在前面
  // mesh.lookAt(camera.position) // 朝向相机
  mesh.scale.set(0, 0, 0) // 初始缩放为0
  return mesh
}

// 计算包围盒  
const calculateBoundingBox = (model: any) => {
  const box3 = new THREE.Box3().setFromObject(model);
  // 创建一个线条来显示包围盒(可选)  
  const boxHelper = new THREE.Box3Helper(box3, new THREE.Color(0xffffff));
  boxHelper.name = 'surroundingBox'
  boxHelper.scale.copy(box3.getSize(new THREE.Vector3()));
  scene.add(boxHelper);
}

const translateCamera = (position: any, target: any, duration = 1): Promise<void> => {
  return new Promise((resolve) => {
    tween = gsap.to(camera.position, {
      x: position.x,
      y: position.y,
      z: position.z,
      duration: duration,
      ease: "none",
    });

    tween1 = gsap.to(controls.target, {
      x: target.x,
      y: target.y,
      z: target.z,
      duration: duration,
      ease: "none",
      onComplete: () => resolve() // 动画完成时 resolve Promise
    });
  });
}
// 射线拾取
const raycaster = (event: any, geometrys: any) => {
  const x = ((event.clientX - rect.left) / canvasWidth) * 2 - 1;
  const y = -((event.clientY - rect.top) / canvasHeight) * 2 + 1;
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(new THREE.Vector2(x, y), camera);
  return raycaster.intersectObjects(geometrys);
}

// 后期处理渲染器
const postRenderer = (mesh: any) => {
  if (!mesh) {
    composer = null
    return
  }
  composer = new EffectComposer(renderer)
  const renderPass = new RenderPass(scene, camera);
  composer.addPass(renderPass);

  const v2 = new THREE.Vector2(canvasWidth, canvasHeight)
  const outlinePass = new OutlinePass(v2, scene, camera)
  outlinePass.selectedObjects = [mesh]
  // 将此通道结果渲染到屏幕
  outlinePass.renderToScreen = true;
  outlinePass.edgeGlow = 1; // 发光强度
  outlinePass.usePatternTexture = false; // 是否使用纹理图案
  outlinePass.edgeThickness = 1; // 边缘浓度
  outlinePass.edgeStrength = 2; // 边缘的强度,值越高边框范围越大
  outlinePass.pulsePeriod = 2; // 闪烁频率,值越大频率越低
  outlinePass.visibleEdgeColor.set('#ff0000'); // 呼吸显示的颜色
  outlinePass.hiddenEdgeColor.set('#ffff00'); // 不可见边缘的颜色
  composer.addPass(outlinePass)
}

// 地面 坐标轴 辅助
const initOther = () => {
  setTheGround()
  setTheWall()
  // 坐标轴
  // const axesHelper = new THREE.AxesHelper(50)
  // scene.add(axesHelper)
  container.value.appendChild(stats.domElement)
}
// 设置地面
const setTheGround = () => {
  // 创建纹理
  const texture = textureLoader.load(floorTextureSrc)
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(5, 10) // 横向重复5次,纵向重复10次

  // 添加地面
  const floorMesh = new THREE.Mesh(
    new THREE.PlaneGeometry(50, 100),
    new THREE.MeshPhongMaterial({
      side: THREE.DoubleSide,
      map: texture,
    }));
  floorMesh.name = 'floor'
  floorMesh.receiveShadow = true;
  floorMesh.rotation.x = - Math.PI / 2.0;
  scene.add(floorMesh);
}
// 设置墙面
const setTheWall = () => {
  // 创建纹理
  const texture = textureLoader.load(wallTextureSrc)
  texture.wrapS = THREE.RepeatWrapping
  texture.wrapT = THREE.RepeatWrapping
  texture.repeat.set(10, 1) // 横向重复5次,纵向重复10次

  // 添加墙壁1
  const wallMesh = new THREE.Mesh(
    new THREE.PlaneGeometry(100, 10),
    new THREE.MeshPhongMaterial({
      side: THREE.DoubleSide,
      map: texture,
    }));
  wallMesh.name = 'wall'
  wallMesh.receiveShadow = true;
  wallMesh.rotation.y = - Math.PI / 2.0;
  wallMesh.position.y = 5
  wallMesh.position.x = 25
  // 克隆wallMesh
  const wallMesh1 = wallMesh.clone()
  const wallMesh2 = wallMesh.clone()
  wallMesh2.position.x = -25
  scene.add(wallMesh1);
  scene.add(wallMesh2);
}

// 为指定物体添加文字标注
const addText = (obj: any) => {
  // 1. 创建canvas
  const canvas = document.createElement('canvas')
  canvas.width = 256
  canvas.height = 64
  const ctx = canvas.getContext('2d')!
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.font = 'bold 32px Microsoft YaHei'
  ctx.fillStyle = obj.status == 0 ? 'green' : 'red'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(obj.name, canvas.width / 2, canvas.height / 2)

  // 2. 创建贴图
  const texture = new THREE.CanvasTexture(canvas)
  texture.needsUpdate = true

  // 3. 创建平面几何体
  const aspect = canvas.width / canvas.height
  const geometry = new THREE.PlaneGeometry(2 * aspect, 2)
  const material = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    side: THREE.DoubleSide
  })
  const textMesh = new THREE.Mesh(geometry, material)
  textMesh.position.copy(obj.position)
  textMesh.position.y = 4
  textMesh.myTag = 'text'
  textMesh.fatherTag = obj.name
  scene.add(textMesh)
}

const updateData = () => {
  scene.traverse((child: any) => {
    if (typeof child.myTag !== 'undefined' && child.myTag === 'text') {
      // 找到对应设备
      const device = deviceArr.find((d: any) => d.name === child.fatherTag)
      if (!device) return

      // 重新绘制canvas
      const canvas = document.createElement('canvas')
      canvas.width = 256
      canvas.height = 64
      const ctx = canvas.getContext('2d')!
      ctx.clearRect(0, 0, canvas.width, canvas.height)
      ctx.font = 'bold 32px Microsoft YaHei'
      ctx.fillStyle = device.status == 0 ? 'blue' : 'red'
      ctx.textAlign = 'center'
      ctx.textBaseline = 'middle'
      ctx.fillText(device.name, canvas.width / 2, canvas.height / 2)

      // 更新贴图
      const newTexture = new THREE.CanvasTexture(canvas)
      newTexture.needsUpdate = true

      // 释放旧贴图资源
      if (child.material.map) {
        child.material.map.dispose()
      }
      child.material.map = newTexture
      child.material.needsUpdate = true
    }
  })
}

// 新增:窗口尺寸变化时自适应渲染
const handleResize = () => {
  if (!container.value || !camera || !renderer) return
  canvasWidth = container.value.clientWidth
  canvasHeight = container.value.clientHeight
  camera.aspect = canvasWidth / canvasHeight
  camera.updateProjectionMatrix()
  renderer.setSize(canvasWidth, canvasHeight)
  render()
}


// 新增:鼠标按下时打断gsap动画
const handleMouseDown = () => {
  if (tween) {
    tween.kill();
    tween = null;
  }
  if (tween1) {
    tween1.kill();
    tween1 = null;
  }
}

// 使用 async/await
const autoKanban = async (model: any) => {
  const cameraLocation = model.position

  // 先移除旧的内容
  const findBox3 = scene.children.find((el: any) => el.name == 'surroundingBox')
  if (findBox3) scene.remove(findBox3)

  // 移除旧的3D看板
  if (boardMesh) {
    scene.remove(boardMesh)
    boardMesh.geometry.dispose()
    boardMesh.material.map.dispose()
    boardMesh.material.dispose()
    boardMesh = null
  }

  const location = model.position

  // 等待相机动画完成
  await translateCamera(
    new THREE.Vector3(cameraLocation.x, cameraLocation.y + 5, cameraLocation.z + 20),
    new THREE.Vector3(cameraLocation.x, cameraLocation.y, cameraLocation.z)
  )

  // 相机动画完成后创建3D看板
  boardMesh = create3DBoard(model.name)
  boardMesh.position.copy(location)
  boardMesh.position.y += 8
  scene.add(boardMesh)

  // 添加看板的缩放动画
  gsap.to(boardMesh.scale, {
    x: 1,
    y: 1,
    z: 1,
    duration: 0.5,
    ease: "back.out"
  })

  // 选中物体添加边框
  calculateBoundingBox(model)
}

// 修改定时器逻辑
const executeKanban = async () => {
  if (!isExecution || isAnimating) return // 如果正在执行动画则跳过
  isAnimating = true // 标记开始动画
  await autoKanban(deviceMeshes[boardStartIndex])
  if (boardStartIndex < deviceArr.length - 1) {
    boardStartIndex++
  } else {
    boardStartIndex = 0
  }
  isAnimating = false // 标记动画完成
}

</script>

<style scoped>
* {
  margin: 0;
  padding: 0;
}

.equipmentKK {
  position: relative;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}

#container {
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.btns {
  position: absolute;
  top: 20px;
  right: 20px;
  z-index: 999;
}

button {
  border: none;
  outline: none;
  background-color: #fff;
  padding: 5px 10px;
  margin: 5px;
  cursor: pointer;
}
</style>