فهرست منبع

feat(雷达点云): 添加雷达点云可视化组件及路由配置

新增雷达点云可视化功能,包含以下主要变更:
1. 添加雷达点云组件,支持实时数据渲染和交互
2. 配置点云路由和全屏视图
3. 添加Three.js依赖及相关类型定义
4. 优化vite配置支持Three.js插件
5. 更新组件类型声明文件

组件支持WebSocket数据连接、点云颜色映射、视图控制等功能,提供直观的雷达数据可视化体验
liujia 2 ماه پیش
والد
کامیت
7b1b7f1
8فایلهای تغییر یافته به همراه1001 افزوده شده و 8 حذف شده
  1. 2 0
      components.d.ts
  2. 2 0
      package.json
  3. 59 0
      pnpm-lock.yaml
  4. 880 0
      src/components/radarPointCloud/index.vue
  5. 9 3
      src/router/index.ts
  6. 29 5
      src/views/device/detail/index.vue
  7. 18 0
      src/views/pointCloudMap/index.vue
  8. 2 0
      vite.config.ts

+ 2 - 0
components.d.ts

@@ -54,6 +54,8 @@ declare module 'vue' {
     FurnitureIcon: typeof import('./src/components/furnitureIcon/index.vue')['default']
     FurnitureItem: typeof import('./src/components/furnitureItem/index.vue')['default']
     FurnitureList: typeof import('./src/components/furnitureList/index.vue')['default']
+    RadarCloudPointsMap: typeof import('./src/components/radarCloudPointsMap/index.vue')['default']
+    RadarPointCloud: typeof import('./src/components/radarPointCloud/index.vue')['default']
     RangePicker: typeof import('./src/components/rangePicker/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 2 - 0
package.json

@@ -40,6 +40,7 @@
     "pinia": "^3.0.3",
     "pinia-plugin-persistedstate": "^4.4.1",
     "pinyin-pro": "^3.26.0",
+    "three": "^0.179.1",
     "vue": "^3.5.17",
     "vue-router": "^4.5.1"
   },
@@ -48,6 +49,7 @@
     "@types/echarts": "^5.0.0",
     "@types/mqtt": "^2.5.0",
     "@types/node": "^22.15.32",
+    "@types/three": "^0.179.0",
     "@vitejs/plugin-vue": "^6.0.0",
     "@vue/eslint-config-prettier": "^10.2.0",
     "@vue/eslint-config-typescript": "^14.5.1",

+ 59 - 0
pnpm-lock.yaml

@@ -53,6 +53,9 @@ importers:
       pinyin-pro:
         specifier: ^3.26.0
         version: 3.26.0
+      three:
+        specifier: ^0.179.1
+        version: 0.179.1
       vue:
         specifier: ^3.5.17
         version: 3.5.17(typescript@5.8.3)
@@ -72,6 +75,9 @@ importers:
       '@types/node':
         specifier: ^22.15.32
         version: 22.16.0
+      '@types/three':
+        specifier: ^0.179.0
+        version: 0.179.0
       '@vitejs/plugin-vue':
         specifier: ^6.0.0
         version: 6.0.0(vite@7.0.2(@types/node@22.16.0)(jiti@2.4.2)(less@4.3.0))(vue@3.5.17(typescript@5.8.3))
@@ -295,6 +301,9 @@ packages:
     resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
     engines: {node: '>=10'}
 
+  '@dimforge/rapier3d-compat@0.12.0':
+    resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
+
   '@emotion/hash@0.9.2':
     resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
 
@@ -819,6 +828,9 @@ packages:
   '@tsconfig/node22@22.0.2':
     resolution: {integrity: sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA==}
 
+  '@tweenjs/tween.js@23.1.3':
+    resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
+
   '@types/echarts@5.0.0':
     resolution: {integrity: sha512-5uc/16BlYpzH8kU/u8aeRRgY2FV6yRY7RjPnYfUFPowl0F3kvNgfaz09PmeVdLkqdAtMft3XkCfqiJPJjG2DNQ==}
     deprecated: This is a stub types definition. echarts provides its own type definitions, so you do not need this installed.
@@ -848,9 +860,18 @@ packages:
   '@types/readable-stream@4.0.21':
     resolution: {integrity: sha512-19eKVv9tugr03IgfXlA9UVUVRbW6IuqRO5B92Dl4a6pT7K8uaGrNS0GkxiZD0BOk6PLuXl5FhWl//eX/pzYdTQ==}
 
+  '@types/stats.js@0.17.4':
+    resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
+
+  '@types/three@0.179.0':
+    resolution: {integrity: sha512-VgbFG2Pgsm84BqdegZzr7w2aKbQxmgzIu4Dy7/75ygiD/0P68LKmp5ie08KMPNqGTQwIge8s6D1guZf1RnZE0A==}
+
   '@types/web-bluetooth@0.0.21':
     resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
 
+  '@types/webxr@0.5.22':
+    resolution: {integrity: sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==}
+
   '@typescript-eslint/eslint-plugin@8.35.1':
     resolution: {integrity: sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1045,6 +1066,9 @@ packages:
     peerDependencies:
       vue: ^3.5.0
 
+  '@webgpu/types@0.1.64':
+    resolution: {integrity: sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A==}
+
   abort-controller@3.0.0:
     resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
     engines: {node: '>=6.5'}
@@ -1625,6 +1649,9 @@ packages:
       picomatch:
         optional: true
 
+  fflate@0.8.2:
+    resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+
   figures@6.1.0:
     resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
     engines: {node: '>=18'}
@@ -2092,6 +2119,9 @@ packages:
     resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
     engines: {node: '>= 8'}
 
+  meshoptimizer@0.22.0:
+    resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
+
   micromatch@4.0.8:
     resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
     engines: {node: '>=8.6'}
@@ -2635,6 +2665,9 @@ packages:
   text-decoder@1.2.3:
     resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
 
+  three@0.179.1:
+    resolution: {integrity: sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==}
+
   throttle-debounce@5.0.2:
     resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
     engines: {node: '>=12.22'}
@@ -3162,6 +3195,8 @@ snapshots:
 
   '@ctrl/tinycolor@3.6.1': {}
 
+  '@dimforge/rapier3d-compat@0.12.0': {}
+
   '@emotion/hash@0.9.2': {}
 
   '@emotion/unitless@0.8.1': {}
@@ -3549,6 +3584,8 @@ snapshots:
 
   '@tsconfig/node22@22.0.2': {}
 
+  '@tweenjs/tween.js@23.1.3': {}
+
   '@types/echarts@5.0.0':
     dependencies:
       echarts: 5.6.0
@@ -3581,8 +3618,22 @@ snapshots:
     dependencies:
       '@types/node': 22.16.0
 
+  '@types/stats.js@0.17.4': {}
+
+  '@types/three@0.179.0':
+    dependencies:
+      '@dimforge/rapier3d-compat': 0.12.0
+      '@tweenjs/tween.js': 23.1.3
+      '@types/stats.js': 0.17.4
+      '@types/webxr': 0.5.22
+      '@webgpu/types': 0.1.64
+      fflate: 0.8.2
+      meshoptimizer: 0.22.0
+
   '@types/web-bluetooth@0.0.21': {}
 
+  '@types/webxr@0.5.22': {}
+
   '@typescript-eslint/eslint-plugin@8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)':
     dependencies:
       '@eslint-community/regexpp': 4.12.1
@@ -3872,6 +3923,8 @@ snapshots:
     dependencies:
       vue: 3.5.17(typescript@5.8.3)
 
+  '@webgpu/types@0.1.64': {}
+
   abort-controller@3.0.0:
     dependencies:
       event-target-shim: 5.0.1
@@ -4602,6 +4655,8 @@ snapshots:
     optionalDependencies:
       picomatch: 4.0.2
 
+  fflate@0.8.2: {}
+
   figures@6.1.0:
     dependencies:
       is-unicode-supported: 2.1.0
@@ -5053,6 +5108,8 @@ snapshots:
 
   merge2@1.4.1: {}
 
+  meshoptimizer@0.22.0: {}
+
   micromatch@4.0.8:
     dependencies:
       braces: 3.0.3
@@ -5655,6 +5712,8 @@ snapshots:
     dependencies:
       b4a: 1.6.7
 
+  three@0.179.1: {}
+
   throttle-debounce@5.0.2: {}
 
   tinyglobby@0.2.14:

+ 880 - 0
src/components/radarPointCloud/index.vue

@@ -0,0 +1,880 @@
+<template>
+  <div class="radar-point-cloud">
+    <!-- 点云渲染容器 -->
+    <div ref="container" class="point-cloud-container">
+      <!-- 加载状态 -->
+      <div class="loading" v-if="isLoading">
+        <div class="spinner"></div>
+        <div class="loading-text">{{ loadingText }}</div>
+      </div>
+
+      <!-- 信息面板 -->
+      <div class="overlay">
+        <h3>雷达点云图例</h3>
+        <div class="legend-item">
+          <div class="legend-color" style="background-color: #ff0000"></div>
+          <span>强度(1800-2000)</span>
+        </div>
+        <div class="legend-item">
+          <div class="legend-color" style="background-color: #ffff00"></div>
+          <span>强度(1600-1800)</span>
+        </div>
+        <div class="legend-item">
+          <div class="legend-color" style="background-color: #00ff00"></div>
+          <span>强度(1400-1600)</span>
+        </div>
+        <div class="legend-item">
+          <div class="legend-color" style="background-color: #00ffff"></div>
+          <span>强度(1200-1400)</span>
+        </div>
+        <div class="legend-item">
+          <div class="legend-color" style="background-color: #0000ff"></div>
+          <span>强度(1000-1200)</span>
+        </div>
+        <p
+          >当前点数量: <span>{{ pointCount }}</span></p
+        >
+        <p
+          >最后更新: <span>{{ lastUpdate }}</span></p
+        >
+        <button class="reset-button" @click="resetView" :disabled="isLoading"> 重置视图 </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+// 导入Vue相关依赖
+import { ref, onMounted, onUnmounted, reactive, toRefs, nextTick } from 'vue'
+// 导入Three.js核心库和相关扩展
+import * as THREE from 'three'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
+import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'
+import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js'
+import { CopyShader } from 'three/examples/jsm/shaders/CopyShader.js'
+
+// 定义组件名称
+defineOptions({
+  name: 'RadarPointCloud',
+})
+
+// 类型定义
+interface RadarDataPayload {
+  RawPoints: [number, number, number, number][] // 原始点数据[x, y, z, 强度]
+  timestamp: string // 时间戳
+}
+
+interface RadarData {
+  ID: string // 数据ID
+  Payload: RadarDataPayload // 数据负载
+  Type: string // 数据类型
+}
+
+interface RoomDimensions {
+  width: number // 房间宽度
+  length: number // 房间长度
+  height: number // 房间高度
+}
+
+interface RgbColor {
+  r: number // 红色通道(0-1)
+  g: number // 绿色通道(0-1)
+  b: number // 蓝色通道(0-1)
+}
+
+// 存储初始相机位置和目标点
+let initialCameraPosition = new THREE.Vector3()
+let initialCameraTarget = new THREE.Vector3()
+// 存储初始相机方向向量(用于保持视角方向)
+let initialCameraDirection = new THREE.Vector3()
+
+// 状态管理
+const state = reactive({
+  isLoading: true, // 加载状态
+  loadingText: '正在加载雷达点云数据...', // 加载提示文本
+  pointCount: 0, // 点数量
+  lastUpdate: '-', // 最后更新时间
+})
+
+// 将响应式状态转换为ref以便在模板中使用
+const { isLoading, loadingText, pointCount, lastUpdate } = toRefs(state)
+
+// DOM引用 - 用于获取渲染容器
+const container = ref<HTMLDivElement | null>(null)
+
+// Three.js相关变量
+let scene: THREE.Scene | null = null // 场景
+let camera: THREE.PerspectiveCamera | null = null // 相机
+let renderer: THREE.WebGLRenderer | null = null // 渲染器
+let controls: OrbitControls | null = null // 控制器
+let composer: EffectComposer | null = null // 后期处理合成器
+let pointCloud: THREE.Points | null = null // 点云对象
+let ws: WebSocket | null = null // WebSocket连接
+let animationId: number | null = null // 动画帧ID
+let glassWalls: THREE.Mesh[] = [] // 玻璃墙集合
+let floorPoints: THREE.Points | null = null // 地板点
+let initializationAttempts = 0 // 初始化尝试次数
+const MAX_INIT_ATTEMPTS = 5 // 最大初始化尝试次数
+
+// 房间尺寸配置(明确边界)
+const roomDimensions: RoomDimensions = {
+  width: 400, // X轴范围:-200 ~ 200
+  length: 400, // Z轴范围:-200 ~ 200
+  height: 280, // Y轴范围:0 ~ 280
+}
+
+/**
+ * 安全地释放材质资源
+ * @param material 要释放的材质,可以是单个材质或材质数组
+ */
+function disposeMaterial(material: THREE.Material | THREE.Material[] | null) {
+  if (!material) return
+
+  if (Array.isArray(material)) {
+    material.forEach((m) => m.dispose())
+  } else {
+    material.dispose()
+  }
+}
+
+/**
+ * 计算房间的三维边界框
+ * 明确房间的最小/最大坐标范围
+ */
+const getRoomBoundingBox = (): THREE.Box3 => {
+  return new THREE.Box3(
+    new THREE.Vector3(-roomDimensions.width / 2, 0, -roomDimensions.length / 2), // 最小点
+    new THREE.Vector3(roomDimensions.width / 2, roomDimensions.height, roomDimensions.length / 2) // 最大点
+  )
+}
+
+/**
+ * 计算「刚好能完整显示整个房间」的相机距离
+ * 用于自动调整和重置视图
+ */
+function calculateOptimalDistance() {
+  if (!camera || !container.value) return 0
+
+  const roomBbox = getRoomBoundingBox()
+  const canvasAspect = container.value.offsetWidth / container.value.offsetHeight // 画布宽高比
+  const cameraFov = camera.fov * (Math.PI / 180) // 相机垂直视野(弧度)
+
+  // 1. 计算房间在三维空间中的最大尺寸(对角线)
+  const roomSize = new THREE.Vector3()
+  roomBbox.getSize(roomSize)
+  const roomDiagonal = roomSize.length() // 房间空间对角线长度
+
+  // 2. 根据相机视锥体计算最小距离:确保对角线能被视野完全容纳
+  const minDistance = roomDiagonal / 2 / Math.tan(cameraFov / 2)
+
+  // 3. 宽高比修正
+  const aspectFactor = Math.min(1.2, canvasAspect)
+
+  // 4. 最终距离 = 基础最小距离 × 修正系数(留少量冗余)
+  return minDistance * aspectFactor * 1.1
+}
+
+/**
+ * 初始化Three.js场景
+ * 创建场景、相机、渲染器等核心组件
+ * @returns 是否初始化成功
+ */
+async function initThreeJs() {
+  initializationAttempts++
+
+  // 超过最大尝试次数则停止
+  if (initializationAttempts > MAX_INIT_ATTEMPTS) {
+    console.error(`超过最大初始化尝试次数(${MAX_INIT_ATTEMPTS}),请检查容器元素`)
+    state.loadingText = '初始化失败:容器无法加载,请刷新页面重试'
+    return false
+  }
+
+  // 检查容器是否存在
+  if (!container.value) {
+    console.error('容器元素不存在,无法初始化Three.js')
+    state.loadingText = '初始化失败:容器元素不存在'
+    return false
+  }
+
+  // 等待DOM更新完成
+  await nextTick()
+  const { offsetWidth: width, offsetHeight: height } = container.value
+
+  // 检查容器尺寸有效性
+  if (width === 0 || height === 0) {
+    console.warn(`容器尺寸无效 (${width}x${height}),1秒后重试...`)
+    state.loadingText = `等待容器加载...(${initializationAttempts}/${MAX_INIT_ATTEMPTS})`
+    setTimeout(initThreeJs, 1000)
+    return false
+  }
+
+  try {
+    // 创建场景
+    scene = new THREE.Scene()
+    scene.background = new THREE.Color(0x000104) // 深色背景
+    scene.fog = new THREE.FogExp2(0x000104, 0.0000675) // 雾化效果
+
+    // 创建透视相机
+    camera = new THREE.PerspectiveCamera(45, width / height, 1, 50000)
+
+    // 创建WebGL渲染器
+    renderer = new THREE.WebGLRenderer({
+      antialias: true, // 抗锯齿
+      alpha: true, // 透明背景
+    })
+    renderer.setSize(width, height) // 设置渲染尺寸
+    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) // 限制像素比,平衡性能和质量
+    renderer.autoClear = false // 禁用自动清除
+    container.value.appendChild(renderer.domElement) // 将渲染画布添加到容器
+
+    // 创建轨道控制器
+    if (camera) {
+      controls = new OrbitControls(camera, renderer.domElement)
+      controls.enableDamping = true // 启用阻尼效果(平滑动画)
+      controls.dampingFactor = 0.05 // 阻尼系数
+      // 恢复鼠标滚轮缩放功能 - 不限制过严
+      controls.minDistance = 100 // 允许足够近的缩放
+      controls.maxDistance = 2000 // 允许足够远的缩放
+    }
+
+    // 设置相机和控制器并保存初始位置
+    setupCameraAndControls()
+
+    // 添加环境光
+    const ambientLight = new THREE.AmbientLight(0x404040, 1.2)
+    scene.add(ambientLight)
+
+    // 添加方向光
+    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
+    directionalLight.position.set(500, 1000, 500) // 放置在斜上方
+    scene.add(directionalLight)
+
+    // 创建点云容器
+    pointCloud = new THREE.Points()
+    scene.add(pointCloud)
+
+    // 创建房间和地板
+    createRoom()
+    createFloorPoints()
+
+    // 设置后期处理
+    setupPostProcessing(width, height)
+
+    state.loadingText = '初始化完成,等待数据...'
+    return true
+  } catch (error) {
+    console.error('Three.js初始化失败', error)
+    state.loadingText = `初始化失败: ${(error as Error).message}`
+    cleanupThreeJs() // 初始化失败时清理资源
+    return false
+  }
+}
+
+/**
+ * 设置相机和控制器初始状态
+ * 恢复正视角度
+ */
+function setupCameraAndControls() {
+  if (!camera || !controls) {
+    console.error('相机或控制器未初始化,无法设置初始位置')
+    return
+  }
+
+  const roomBbox = getRoomBoundingBox()
+  const roomCenter = new THREE.Vector3()
+  roomBbox.getCenter(roomCenter) // 房间中心点(目标点)
+
+  // 调整目标点高度,更符合正视视角
+  roomCenter.y = roomDimensions.height / 3
+
+  // 计算刚好能完整显示房间的距离
+  const initialDistance = calculateOptimalDistance()
+
+  // 恢复正视角度(主要从Z轴方向观察)
+  const initialPosition = new THREE.Vector3(
+    0, // X轴:不偏移,保持正视
+    roomCenter.y + initialDistance * 0.2, // Y轴:轻微高度偏移
+    roomCenter.z + initialDistance * 0.6 // Z轴:主要观察方向
+  )
+
+  // 设置相机和控制器
+  camera.position.copy(initialPosition)
+  controls.target.copy(roomCenter)
+  camera.lookAt(roomCenter)
+
+  // 保存初始参数(用于重置)
+  initialCameraPosition.copy(initialPosition)
+  initialCameraTarget.copy(roomCenter)
+  initialCameraDirection.subVectors(initialPosition, roomCenter).normalize()
+
+  controls.update()
+}
+
+/**
+ * 重置视图到初始状态
+ * 回到自动调整的最佳视角
+ */
+function resetView() {
+  if (!camera || !controls) {
+    console.warn('无法重置视图:相机或控制器未初始化')
+    return
+  }
+
+  // 计算当前画布尺寸下的最佳距离
+  const optimalDistance = calculateOptimalDistance()
+
+  // 计算目标位置(保持初始方向)
+  const zoomNumber = 0.75 // 视图远近系数 数值越小,视角越近
+  const targetPosition = new THREE.Vector3()
+  targetPosition.copy(initialCameraTarget)
+  targetPosition.addScaledVector(initialCameraDirection, optimalDistance * zoomNumber)
+
+  // 平滑过渡动画
+  const duration = 500
+  const start_time = performance.now()
+  const start_position = new THREE.Vector3().copy(camera.position)
+  const start_target = new THREE.Vector3().copy(controls.target)
+
+  const animateReset = (current_time: number) => {
+    if (!camera || !controls) return
+
+    const elapsed = current_time - start_time
+    const progress = Math.min(elapsed / duration, 1)
+    const easeProgress = 1 - Math.pow(1 - progress, 3)
+
+    camera.position.lerpVectors(start_position, targetPosition, easeProgress)
+    controls.target.lerpVectors(start_target, initialCameraTarget, easeProgress)
+    controls.update()
+
+    if (progress < 1) {
+      requestAnimationFrame(animateReset)
+    }
+  }
+
+  requestAnimationFrame(animateReset)
+}
+
+/**
+ * 创建房间的玻璃墙壁
+ */
+function createRoom() {
+  if (!scene) return
+
+  const room = new THREE.Group()
+
+  // 透明墙壁材质
+  const glassMat = new THREE.MeshStandardMaterial({
+    color: 0xd1eaff,
+    transparent: true,
+    opacity: 0.25,
+    side: THREE.DoubleSide,
+  })
+
+  // 后墙
+  const backWall = new THREE.Mesh(
+    new THREE.PlaneGeometry(roomDimensions.width, roomDimensions.height),
+    glassMat
+  )
+  backWall.position.set(0, roomDimensions.height / 2, -roomDimensions.length / 2)
+  room.add(backWall)
+  glassWalls.push(backWall)
+
+  // 左墙
+  const leftWall = new THREE.Mesh(
+    new THREE.PlaneGeometry(roomDimensions.length, roomDimensions.height),
+    glassMat
+  )
+  leftWall.position.set(-roomDimensions.width / 2, roomDimensions.height / 2, 0)
+  leftWall.rotation.y = Math.PI / 2
+  room.add(leftWall)
+  glassWalls.push(leftWall)
+
+  // 右墙
+  const rightWall = new THREE.Mesh(
+    new THREE.PlaneGeometry(roomDimensions.length, roomDimensions.height),
+    glassMat
+  )
+  rightWall.position.set(roomDimensions.width / 2, roomDimensions.height / 2, 0)
+  rightWall.rotation.y = -Math.PI / 2
+  room.add(rightWall)
+  glassWalls.push(rightWall)
+
+  scene.add(room)
+}
+
+/**
+ * 创建地板粒子网格
+ */
+function createFloorPoints() {
+  if (!scene) return
+
+  // 创建平面几何体作为地板
+  const geometry = new THREE.PlaneGeometry(roomDimensions.width, roomDimensions.length, 64, 64)
+
+  // 创建点材质的地板
+  floorPoints = new THREE.Points(
+    geometry,
+    new THREE.PointsMaterial({
+      color: 0x336699,
+      size: 10,
+    })
+  )
+  floorPoints.position.y = -10
+  floorPoints.rotation.x = -Math.PI / 2
+  scene.add(floorPoints)
+}
+
+/**
+ * 设置后期处理效果
+ * @param width 画布宽度
+ * @param height 画布高度
+ */
+function setupPostProcessing(width: number, height: number) {
+  if (!renderer || !scene || !camera) return
+
+  try {
+    if (typeof EffectComposer === 'undefined') {
+      throw new Error('EffectComposer不可用')
+    }
+
+    // 创建合成器
+    composer = new EffectComposer(renderer)
+    composer.setSize(width, height)
+
+    // 添加渲染通道
+    const renderModel = new RenderPass(scene, camera)
+    composer.addPass(renderModel)
+
+    // 添加Bloom效果
+    if (typeof BloomPass !== 'undefined') {
+      try {
+        const effectBloom = new BloomPass(0.4)
+        composer.addPass(effectBloom)
+      } catch (error) {
+        console.warn('Bloom效果初始化失败,已跳过', error)
+      }
+    }
+
+    // 添加输出通道
+    if (typeof CopyShader !== 'undefined' && typeof ShaderPass !== 'undefined') {
+      const outputPass = new ShaderPass(CopyShader) as ShaderPass & { renderToScreen: boolean }
+      outputPass.renderToScreen = true
+      composer.addPass(outputPass)
+    } else {
+      console.warn('CopyShader不可用,使用默认渲染')
+      composer = null
+    }
+  } catch (error) {
+    console.error('后期处理初始化失败,使用默认渲染', error)
+    composer = null
+  }
+}
+
+/**
+ * 更新点云数据
+ * @param rawPoints 原始点数据数组
+ */
+function updatePointCloud(rawPoints: [number, number, number, number][]) {
+  if (!pointCloud) {
+    console.error('pointCloud未初始化,无法更新点云数据')
+    state.loadingText = '点云组件未准备好,请稍候...'
+    return
+  }
+
+  try {
+    // 清除现有点数据
+    if (pointCloud.geometry) {
+      pointCloud.geometry.dispose()
+    }
+    disposeMaterial(pointCloud.material)
+
+    const pointCountValue = rawPoints.length
+    // 创建位置和颜色数组
+    const positions = new Float32Array(pointCountValue * 3)
+    const colors = new Float32Array(pointCountValue * 3)
+
+    // 填充位置和颜色数据
+    rawPoints.forEach((point, index) => {
+      const [x, y, z, intensity] = point
+      const idx = index * 3
+
+      // 设置位置
+      positions[idx] = x
+      positions[idx + 1] = y
+      positions[idx + 2] = z
+
+      // 归一化强度:假设范围是 [1000, 2000]
+      const normalizedIntensity = Math.min(Math.max((intensity - 1000) / 1000, 0), 1)
+
+      // 获取插值颜色
+      const { r, g, b } = getInterpolatedColor(normalizedIntensity)
+
+      colors[idx] = r
+      colors[idx + 1] = g
+      colors[idx + 2] = b
+    })
+
+    // 创建点云几何和材质
+    const geometry = new THREE.BufferGeometry()
+    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
+
+    const material = new THREE.PointsMaterial({
+      size: 8,
+      vertexColors: true,
+      transparent: true,
+      opacity: 0.8,
+      sizeAttenuation: true,
+    })
+
+    pointCloud.geometry = geometry
+    pointCloud.material = material
+
+    // 更新UI显示
+    pointCount.value = pointCountValue
+    lastUpdate.value = new Date().toLocaleTimeString()
+  } catch (error) {
+    console.error('更新点云数据失败', error)
+  }
+}
+
+/**
+ * 根据强度值获取插值颜色
+ * @param value 归一化的强度值(0-1)
+ * @returns RGB颜色对象
+ */
+function getInterpolatedColor(value: number): RgbColor {
+  // 强度到颜色的渐变映射
+  const intensityGradientSmooth = [
+    { value: 0.0, color: '#0000FF' }, // 蓝
+    { value: 0.25, color: '#00FFFF' }, // 青
+    { value: 0.5, color: '#00FF00' }, // 绿
+    { value: 0.75, color: '#FFFF00' }, // 黄
+    { value: 1.0, color: '#FF0000' }, // 红
+  ]
+
+  // 找到对应的颜色区间并插值
+  for (let i = 0; i < intensityGradientSmooth.length - 1; i++) {
+    const start = intensityGradientSmooth[i]
+    const end = intensityGradientSmooth[i + 1]
+    if (value >= start.value && value <= end.value) {
+      const t = (value - start.value) / (end.value - start.value)
+      const c1 = hexToRgb(start.color)
+      const c2 = hexToRgb(end.color)
+      return {
+        r: c1.r + (c2.r - c1.r) * t,
+        g: c1.g + (c2.g - c1.g) * t,
+        b: c1.b + (c2.b - c1.b) * t,
+      }
+    }
+  }
+
+  // 默认返回最后一个颜色
+  return hexToRgb(intensityGradientSmooth[intensityGradientSmooth.length - 1].color)
+}
+
+/**
+ * 将十六进制颜色转换为RGB颜色
+ * @param hex 十六进制颜色字符串
+ * @returns RGB颜色对象(通道值0-1)
+ */
+function hexToRgb(hex: string): RgbColor {
+  const bigint = parseInt(hex.slice(1), 16)
+  return {
+    r: ((bigint >> 16) & 255) / 255,
+    g: ((bigint >> 8) & 255) / 255,
+    b: (bigint & 255) / 255,
+  }
+}
+
+/**
+ * 连接WebSocket获取实时数据
+ */
+function connectWebSocket() {
+  // 关闭现有连接
+  if (ws) {
+    ws.close()
+    ws = null
+  }
+
+  // 创建新连接
+  ws = new WebSocket('ws://localhost:8080')
+
+  // 连接成功回调
+  ws.onopen = () => {
+    console.log('WebSocket已连接')
+    loadingText.value = '已连接,接收点云数据中...'
+  }
+
+  // 接收消息回调
+  ws.onmessage = (msg) => {
+    try {
+      if (!pointCloud) {
+        console.warn('点云组件尚未准备好,暂不处理数据')
+        return
+      }
+
+      // 解析雷达数据
+      const radarData: RadarData = JSON.parse(msg.data)
+      if (radarData.Payload && radarData.Payload.RawPoints) {
+        updatePointCloud(radarData.Payload.RawPoints)
+        isLoading.value = false
+      }
+    } catch (e) {
+      console.error('解析雷达数据失败', e)
+    }
+  }
+
+  // 连接关闭回调
+  ws.onclose = () => {
+    console.log('WebSocket已断开,尝试重连...')
+    loadingText.value = '连接断开,尝试重连...'
+    isLoading.value = true
+    setTimeout(connectWebSocket, 3000)
+  }
+
+  // 错误处理回调
+  ws.onerror = (error) => {
+    console.error('WebSocket错误:', error)
+  }
+}
+
+/**
+ * 动画循环
+ */
+function animate() {
+  // 取消上一帧动画
+  if (animationId) {
+    cancelAnimationFrame(animationId)
+  }
+
+  // 动画帧函数
+  const frame = () => {
+    animationId = requestAnimationFrame(frame)
+
+    // 更新控制器
+    if (controls) {
+      controls.update()
+    }
+
+    // 渲染场景
+    if (composer) {
+      composer.render()
+    } else if (renderer && scene && camera) {
+      renderer.render(scene, camera)
+    }
+  }
+
+  // 启动动画循环
+  frame()
+}
+
+/**
+ * 窗口大小变化处理函数
+ * 自动调整到最佳视角,但不限制用户后续操作
+ */
+function onWindowResize() {
+  if (!container.value || !renderer || !camera) return
+
+  const { offsetWidth: width, offsetHeight: height } = container.value
+  if (width === 0 || height === 0) return
+
+  // 更新相机宽高比
+  camera.aspect = width / height
+  camera.updateProjectionMatrix()
+
+  // 更新渲染器尺寸
+  renderer.setSize(width, height)
+
+  // 重新计算最佳距离并自动调整
+  const newDistance = calculateOptimalDistance()
+
+  // 保持视角方向,更新位置
+  const newPosition = new THREE.Vector3()
+  newPosition.copy(initialCameraTarget)
+  newPosition.addScaledVector(initialCameraDirection, newDistance)
+  camera.position.copy(newPosition)
+
+  // 更新合成器尺寸
+  if (composer) {
+    composer.setSize(width, height)
+  }
+}
+
+/**
+ * 清理Three.js资源
+ */
+function cleanupThreeJs() {
+  // 取消动画循环
+  if (animationId) {
+    cancelAnimationFrame(animationId)
+    animationId = null
+  }
+
+  // 释放渲染器
+  if (renderer) {
+    renderer.dispose()
+    renderer = null
+  }
+
+  // 清空场景
+  if (scene) {
+    scene.clear()
+    scene = null
+  }
+
+  // 重置所有引用
+  camera = null
+  controls = null
+  composer = null
+  pointCloud = null
+  floorPoints = null
+  glassWalls = []
+  initialCameraPosition = new THREE.Vector3()
+  initialCameraTarget = new THREE.Vector3()
+  initialCameraDirection = new THREE.Vector3()
+}
+
+// 组件挂载时初始化
+onMounted(() => {
+  nextTick().then(() => {
+    initThreeJs().then((success) => {
+      if (success) {
+        connectWebSocket()
+        animate()
+        window.addEventListener('resize', onWindowResize)
+      }
+    })
+  })
+})
+
+// 组件卸载时清理
+onUnmounted(() => {
+  if (ws) {
+    ws.close()
+    ws = null
+  }
+
+  cleanupThreeJs()
+  window.removeEventListener('resize', onWindowResize)
+})
+</script>
+
+<style scoped lang="less">
+.radar-point-cloud {
+  width: 100%;
+  height: 100%;
+  min-width: 500px;
+  min-height: 300px;
+  display: block;
+  box-sizing: border-box;
+}
+
+.point-cloud-container {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  overflow: hidden;
+  box-sizing: border-box;
+}
+
+.loading {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-direction: column;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 100;
+  &-text {
+    color: #fff;
+    margin-top: 20px;
+  }
+}
+
+.spinner {
+  width: 50px;
+  height: 50px;
+  border: 5px solid rgba(255, 255, 255, 0.3);
+  border-top-color: #3498db;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.overlay {
+  position: absolute;
+  top: 10px;
+  left: 10px;
+  background: rgba(0, 0, 0, 0.7);
+  color: white;
+  padding: 10px;
+  border-radius: 5px;
+  z-index: 10;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  margin: 5px 0;
+}
+
+.legend-color {
+  width: 15px;
+  height: 15px;
+  margin-right: 5px;
+  border-radius: 3px;
+}
+
+.reset-button {
+  margin-top: 10px;
+  padding: 6px 12px;
+  background-color: #3498db;
+  color: white;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  transition: background-color 0.2s;
+}
+
+.reset-button:hover {
+  background-color: #2980b9;
+}
+
+.reset-button:disabled {
+  background-color: #95a5a6;
+  cursor: not-allowed;
+}
+
+.info {
+  position: absolute;
+  bottom: 10px;
+  left: 10px;
+  background: rgba(0, 0, 0, 0.7);
+  color: white;
+  padding: 10px;
+  border-radius: 5px;
+  z-index: 10;
+}
+
+.info a {
+  color: #3498db;
+  text-decoration: none;
+}
+
+.info a:hover {
+  text-decoration: underline;
+}
+</style>

+ 9 - 3
src/router/index.ts

@@ -11,16 +11,22 @@ const moduleRoutes = Object.values(import.meta.glob('./modules/*.ts', { eager: t
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
   routes: [
+    // 自动合并所有模块路由
+    ...moduleRoutes,
     {
       path: '/',
       name: 'home',
       // component: HomeView,
       // meta: { title: '首页看板', isFullScreen: false, keepAlive: false },
       redirect: '/community/list',
-      meta: { title: '小区管理', isFullScreen: false },
+      meta: { title: '小区管理', isFullScreen: false, keepAlive: false },
+    },
+    {
+      path: '/pointCloud',
+      name: 'pointCloud',
+      component: () => import('@/views/pointCloudMap/index.vue'),
+      meta: { title: '设备点云图', isFullScreen: true, keepAlive: false },
     },
-    // 自动合并所有模块路由
-    ...moduleRoutes,
   ],
 })
 router.beforeEach(authGuard)

+ 29 - 5
src/views/device/detail/index.vue

@@ -1,13 +1,24 @@
 <template>
   <a-spin :spinning="spinning">
     <div class="deviceDetail">
+      <!-- <info-card title="雷达云点图(模拟数据)">
+        <template #extra>
+          <div class="extraIcon">
+            <ExportOutlined @click="openWindow" />
+          </div>
+        </template>
+        <div class="pointCloudMap">
+          <RadarPointCloud></RadarPointCloud>
+        </div>
+      </info-card> -->
+
       <info-card title="实时点位图">
         <template #extra>
           <a-space>
             <a-button type="primary" size="small" @click="roomConfigHandler('area')">
               区域配置
             </a-button>
-            <div class="areaZoom"> <FullscreenOutlined @click="openFullView = true" /> </div>
+            <div class="extraIcon"> <FullscreenOutlined @click="openFullView = true" /> </div>
           </a-space>
         </template>
         <a-alert
@@ -246,7 +257,7 @@
 import infoCard from './components/infoCard/index.vue'
 import infoItem from './components/infoItem/index.vue'
 import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
-import { useRoute } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
 import { message } from 'ant-design-vue'
 import * as roomApi from '@/api/room'
 import type { Furniture } from '@/api/room/types'
@@ -258,7 +269,7 @@ import deviceConfigDrawer from './components/deviceConfig/index.vue'
 import deviceStatsDrawer from './components/deviceStatsDrawer/index.vue'
 import BreathLineChart from './components/breathLineChart/index.vue'
 import { formatDateTime } from '@/utils'
-import { FullscreenOutlined } from '@ant-design/icons-vue'
+import { FullscreenOutlined, ExportOutlined } from '@ant-design/icons-vue'
 import FullViewModal from './components/fullViewModal/index.vue'
 
 defineOptions({
@@ -266,6 +277,7 @@ defineOptions({
 })
 
 const route = useRoute()
+const router = useRouter()
 const devId = ref<string>((route.query.devId as string) || '') // 设备id
 const clientId = ref<string>((route.query.clientId as string) || '') // 设备id
 
@@ -513,7 +525,7 @@ onMounted(() => {
             targets[id].lastY = y
             targets[id].displayX = x
             targets[id].displayY = y
-            console.log(`🔄 更新目标点: id=${id}, x=${x}, y=${y}`, targets[id])
+            // console.log(`🔄 更新目标点: id=${id}, x=${x}, y=${y}`, targets[id])
           } else {
             // 距离太小,忽略本次更新
             // console.log(`忽略微小抖动 id=${id}`)
@@ -553,6 +565,12 @@ onUnmounted(() => {
 })
 
 const openFullView = ref(false)
+
+const openWindow = () => {
+  console.log('openWindow')
+  const url = router.resolve({ name: 'pointCloud' }).href
+  window.open(url, '_blank')
+}
 </script>
 
 <style scoped lang="less">
@@ -562,6 +580,12 @@ const openFullView = ref(false)
   gap: 16px;
 }
 
+.pointCloudMap {
+  width: 770px;
+  height: 100%;
+  height: 500px;
+}
+
 .pointMap {
   flex-shrink: 0;
   min-width: 400px;
@@ -591,7 +615,7 @@ const openFullView = ref(false)
   }
 }
 
-.areaZoom {
+.extraIcon {
   font-size: 16px;
   font-weight: 600;
   cursor: pointer;

+ 18 - 0
src/views/pointCloudMap/index.vue

@@ -0,0 +1,18 @@
+<template>
+  <div class="pointCloudMap">
+    <RadarPointCloud></RadarPointCloud>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineOptions({
+  name: 'PointCloudMap',
+})
+</script>
+
+<style scoped lang="less">
+.pointCloudMap {
+  width: 100vw;
+  height: 100vh;
+}
+</style>

+ 2 - 0
vite.config.ts

@@ -8,6 +8,7 @@ import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
 import { readFileSync } from 'fs'
 import { formatDateTime } from './src/utils'
 import buildInfoPlugin from './scripts/build-info-plugin'
+import path from 'path'
 
 // https://vite.dev/config/
 export default defineConfig(({ mode }) => {
@@ -41,6 +42,7 @@ export default defineConfig(({ mode }) => {
     resolve: {
       alias: {
         '@': fileURLToPath(new URL('./src', import.meta.url)),
+        'three/addons': path.join(__dirname, 'node_modules/three/addons'),
       },
     },
     server: {