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