|
@@ -40,8 +40,8 @@
|
|
<!-- 按钮区域 -->
|
|
<!-- 按钮区域 -->
|
|
<a-space class="control-space" :size="12">
|
|
<a-space class="control-space" :size="12">
|
|
<ZoomInOutlined @click="zoomIn" :disabled="isLoading" title="放大" />
|
|
<ZoomInOutlined @click="zoomIn" :disabled="isLoading" title="放大" />
|
|
- <RedoOutlined @click="resetView" :disabled="isLoading" title="重置" />
|
|
|
|
<ZoomOutOutlined @click="zoomOut" :disabled="isLoading" title="缩小" />
|
|
<ZoomOutOutlined @click="zoomOut" :disabled="isLoading" title="缩小" />
|
|
|
|
+ <RedoOutlined @click="resetView" :disabled="isLoading" title="重置视图" />
|
|
<CodeSandboxOutlined
|
|
<CodeSandboxOutlined
|
|
@click="configPanelVisible = !configPanelVisible"
|
|
@click="configPanelVisible = !configPanelVisible"
|
|
:disabled="isLoading"
|
|
:disabled="isLoading"
|
|
@@ -96,7 +96,7 @@
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
// 导入Vue相关依赖
|
|
// 导入Vue相关依赖
|
|
-import { ref, onMounted, onUnmounted, reactive, toRefs, nextTick } from 'vue'
|
|
|
|
|
|
+import { ref, onMounted, onUnmounted, reactive, toRefs, nextTick, watch } from 'vue'
|
|
// 导入Three.js核心库和相关扩展
|
|
// 导入Three.js核心库和相关扩展
|
|
import * as THREE from 'three'
|
|
import * as THREE from 'three'
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
|
@@ -141,6 +141,25 @@ interface RgbColor {
|
|
b: number // 蓝色通道(0-1)
|
|
b: number // 蓝色通道(0-1)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+type Props = {
|
|
|
|
+ data: RadarData | null
|
|
|
|
+ loadingText: string
|
|
|
|
+ isLoading: boolean
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
|
+ data: () => ({
|
|
|
|
+ ID: '',
|
|
|
|
+ Payload: {
|
|
|
|
+ RawPoints: [],
|
|
|
|
+ timestamp: '',
|
|
|
|
+ },
|
|
|
|
+ Type: '',
|
|
|
|
+ }),
|
|
|
|
+ loadingText: '正在加载雷达点云数据...',
|
|
|
|
+ isLoading: true,
|
|
|
|
+})
|
|
|
|
+
|
|
// 存储初始相机位置和目标点
|
|
// 存储初始相机位置和目标点
|
|
let initialCameraPosition = new THREE.Vector3()
|
|
let initialCameraPosition = new THREE.Vector3()
|
|
let initialCameraTarget = new THREE.Vector3()
|
|
let initialCameraTarget = new THREE.Vector3()
|
|
@@ -156,7 +175,7 @@ const state = reactive({
|
|
})
|
|
})
|
|
|
|
|
|
// 将响应式状态转换为ref以便在模板中使用
|
|
// 将响应式状态转换为ref以便在模板中使用
|
|
-const { isLoading, loadingText, pointCount, lastUpdate } = toRefs(state)
|
|
|
|
|
|
+const { pointCount, lastUpdate } = toRefs(state)
|
|
|
|
|
|
// DOM引用 - 用于获取渲染容器
|
|
// DOM引用 - 用于获取渲染容器
|
|
const container = ref<HTMLDivElement | null>(null)
|
|
const container = ref<HTMLDivElement | null>(null)
|
|
@@ -168,7 +187,6 @@ let renderer: THREE.WebGLRenderer | null = null // 渲染器
|
|
let controls: OrbitControls | null = null // 控制器
|
|
let controls: OrbitControls | null = null // 控制器
|
|
let composer: EffectComposer | null = null // 后期处理合成器
|
|
let composer: EffectComposer | null = null // 后期处理合成器
|
|
let pointCloud: THREE.Points | null = null // 点云对象
|
|
let pointCloud: THREE.Points | null = null // 点云对象
|
|
-let ws: WebSocket | null = null // WebSocket连接
|
|
|
|
let animationId: number | null = null // 动画帧ID
|
|
let animationId: number | null = null // 动画帧ID
|
|
let glassWalls: THREE.Mesh[] = [] // 玻璃墙集合
|
|
let glassWalls: THREE.Mesh[] = [] // 玻璃墙集合
|
|
let floorPoints: THREE.Points | null = null // 地板点
|
|
let floorPoints: THREE.Points | null = null // 地板点
|
|
@@ -186,7 +204,7 @@ const roomDimensions = ref<RoomDimensions>({
|
|
* 安全地释放材质资源
|
|
* 安全地释放材质资源
|
|
* @param material 要释放的材质,可以是单个材质或材质数组
|
|
* @param material 要释放的材质,可以是单个材质或材质数组
|
|
*/
|
|
*/
|
|
-function disposeMaterial(material: THREE.Material | THREE.Material[] | null) {
|
|
|
|
|
|
+const disposeMaterial = (material: THREE.Material | THREE.Material[] | null) => {
|
|
if (!material) return
|
|
if (!material) return
|
|
|
|
|
|
if (Array.isArray(material)) {
|
|
if (Array.isArray(material)) {
|
|
@@ -215,7 +233,7 @@ const getRoomBoundingBox = (): THREE.Box3 => {
|
|
* 计算「刚好能完整显示整个房间」的相机距离
|
|
* 计算「刚好能完整显示整个房间」的相机距离
|
|
* 用于自动调整和重置视图
|
|
* 用于自动调整和重置视图
|
|
*/
|
|
*/
|
|
-function calculateOptimalDistance() {
|
|
|
|
|
|
+const calculateOptimalDistance = () => {
|
|
if (!camera || !container.value) return 0
|
|
if (!camera || !container.value) return 0
|
|
|
|
|
|
const roomBbox = getRoomBoundingBox()
|
|
const roomBbox = getRoomBoundingBox()
|
|
@@ -242,7 +260,7 @@ function calculateOptimalDistance() {
|
|
* 创建场景、相机、渲染器等核心组件
|
|
* 创建场景、相机、渲染器等核心组件
|
|
* @returns 是否初始化成功
|
|
* @returns 是否初始化成功
|
|
*/
|
|
*/
|
|
-async function initThreeJs() {
|
|
|
|
|
|
+const initThreeJs = async () => {
|
|
initializationAttempts++
|
|
initializationAttempts++
|
|
|
|
|
|
// 超过最大尝试次数则停止
|
|
// 超过最大尝试次数则停止
|
|
@@ -337,7 +355,7 @@ async function initThreeJs() {
|
|
* 设置相机和控制器初始状态
|
|
* 设置相机和控制器初始状态
|
|
* 恢复正视角度
|
|
* 恢复正视角度
|
|
*/
|
|
*/
|
|
-function setupCameraAndControls() {
|
|
|
|
|
|
+const setupCameraAndControls = () => {
|
|
if (!camera || !controls) {
|
|
if (!camera || !controls) {
|
|
console.error('相机或控制器未初始化,无法设置初始位置')
|
|
console.error('相机或控制器未初始化,无法设置初始位置')
|
|
return
|
|
return
|
|
@@ -377,17 +395,15 @@ function setupCameraAndControls() {
|
|
* 重置视图到初始状态
|
|
* 重置视图到初始状态
|
|
* 回到自动调整的最佳视角
|
|
* 回到自动调整的最佳视角
|
|
*/
|
|
*/
|
|
-function resetView() {
|
|
|
|
|
|
+const resetView = () => {
|
|
if (!camera || !controls) {
|
|
if (!camera || !controls) {
|
|
console.warn('无法重置视图:相机或控制器未初始化')
|
|
console.warn('无法重置视图:相机或控制器未初始化')
|
|
return
|
|
return
|
|
}
|
|
}
|
|
|
|
|
|
- // 计算当前画布尺寸下的最佳距离
|
|
|
|
- const optimalDistance = calculateOptimalDistance()
|
|
|
|
-
|
|
|
|
// 计算目标位置(保持初始方向)
|
|
// 计算目标位置(保持初始方向)
|
|
const zoomNumber = 0.75 // 视图远近系数 数值越小,视角越近
|
|
const zoomNumber = 0.75 // 视图远近系数 数值越小,视角越近
|
|
|
|
+ const optimalDistance = calculateOptimalDistance()
|
|
const targetPosition = new THREE.Vector3()
|
|
const targetPosition = new THREE.Vector3()
|
|
targetPosition.copy(initialCameraTarget)
|
|
targetPosition.copy(initialCameraTarget)
|
|
targetPosition.addScaledVector(initialCameraDirection, optimalDistance * zoomNumber)
|
|
targetPosition.addScaledVector(initialCameraDirection, optimalDistance * zoomNumber)
|
|
@@ -420,7 +436,7 @@ function resetView() {
|
|
/**
|
|
/**
|
|
* 放大视图
|
|
* 放大视图
|
|
*/
|
|
*/
|
|
-function zoomIn() {
|
|
|
|
|
|
+const zoomIn = () => {
|
|
if (!controls || !camera) return
|
|
if (!controls || !camera) return
|
|
|
|
|
|
// 获取当前相机到目标点的距离
|
|
// 获取当前相机到目标点的距离
|
|
@@ -439,7 +455,7 @@ function zoomIn() {
|
|
/**
|
|
/**
|
|
* 缩小视图
|
|
* 缩小视图
|
|
*/
|
|
*/
|
|
-function zoomOut() {
|
|
|
|
|
|
+const zoomOut = () => {
|
|
if (!controls || !camera) return
|
|
if (!controls || !camera) return
|
|
|
|
|
|
// 获取当前相机到目标点的距离
|
|
// 获取当前相机到目标点的距离
|
|
@@ -457,7 +473,7 @@ function zoomOut() {
|
|
/**
|
|
/**
|
|
* 创建房间的玻璃墙壁
|
|
* 创建房间的玻璃墙壁
|
|
*/
|
|
*/
|
|
-function createRoom() {
|
|
|
|
|
|
+const createRoom = () => {
|
|
if (!scene) return
|
|
if (!scene) return
|
|
|
|
|
|
const room = new THREE.Group()
|
|
const room = new THREE.Group()
|
|
@@ -526,7 +542,7 @@ function createRoom() {
|
|
/**
|
|
/**
|
|
* 创建地板粒子网格
|
|
* 创建地板粒子网格
|
|
*/
|
|
*/
|
|
-function createFloorPoints() {
|
|
|
|
|
|
+const createFloorPoints = () => {
|
|
if (!scene) return
|
|
if (!scene) return
|
|
|
|
|
|
// 创建平面几何体作为地板
|
|
// 创建平面几何体作为地板
|
|
@@ -557,7 +573,7 @@ function createFloorPoints() {
|
|
* @param width 画布宽度
|
|
* @param width 画布宽度
|
|
* @param height 画布高度
|
|
* @param height 画布高度
|
|
*/
|
|
*/
|
|
-function setupPostProcessing(width: number, height: number) {
|
|
|
|
|
|
+const setupPostProcessing = (width: number, height: number) => {
|
|
if (!renderer || !scene || !camera) return
|
|
if (!renderer || !scene || !camera) return
|
|
|
|
|
|
try {
|
|
try {
|
|
@@ -602,7 +618,7 @@ function setupPostProcessing(width: number, height: number) {
|
|
* 更新点云数据
|
|
* 更新点云数据
|
|
* @param rawPoints 原始点数据数组
|
|
* @param rawPoints 原始点数据数组
|
|
*/
|
|
*/
|
|
-function updatePointCloud(rawPoints: [number, number, number, number][]) {
|
|
|
|
|
|
+const updatePointCloud = (rawPoints: [number, number, number, number][]) => {
|
|
if (!pointCloud) {
|
|
if (!pointCloud) {
|
|
console.error('pointCloud未初始化,无法更新点云数据')
|
|
console.error('pointCloud未初始化,无法更新点云数据')
|
|
state.loadingText = '点云组件未准备好,请稍候...'
|
|
state.loadingText = '点云组件未准备好,请稍候...'
|
|
@@ -635,9 +651,6 @@ function updatePointCloud(rawPoints: [number, number, number, number][]) {
|
|
const idx = index * 3
|
|
const idx = index * 3
|
|
|
|
|
|
// 设置位置(原始单位m 需要转为cm * 100)
|
|
// 设置位置(原始单位m 需要转为cm * 100)
|
|
- // positions[idx] = x
|
|
|
|
- // positions[idx + 1] = y
|
|
|
|
- // positions[idx + 2] = z
|
|
|
|
positions[idx] = x * 100
|
|
positions[idx] = x * 100
|
|
positions[idx + 1] = y * 100
|
|
positions[idx + 1] = y * 100
|
|
positions[idx + 2] = z * 100
|
|
positions[idx + 2] = z * 100
|
|
@@ -686,14 +699,8 @@ const intensityGradientSmooth = [
|
|
{ value: 1.0, color: '#FF0000' }, // 红
|
|
{ value: 1.0, color: '#FF0000' }, // 红
|
|
]
|
|
]
|
|
|
|
|
|
-// 预计算成 RGB,减少运行时开销
|
|
|
|
-const gradientStops = intensityGradientSmooth.map((s) => ({
|
|
|
|
- value: s.value,
|
|
|
|
- rgb: hexToRgb(s.color),
|
|
|
|
-}))
|
|
|
|
-
|
|
|
|
/** 将十六进制颜色转换为 0–1 的 RGB */
|
|
/** 将十六进制颜色转换为 0–1 的 RGB */
|
|
-function hexToRgb(hex: string): RgbColor {
|
|
|
|
|
|
+const hexToRgb = (hex: string): RgbColor => {
|
|
const n = parseInt(hex.slice(1), 16)
|
|
const n = parseInt(hex.slice(1), 16)
|
|
return {
|
|
return {
|
|
r: ((n >> 16) & 255) / 255,
|
|
r: ((n >> 16) & 255) / 255,
|
|
@@ -702,8 +709,14 @@ function hexToRgb(hex: string): RgbColor {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+// 预计算成 RGB,减少运行时开销
|
|
|
|
+const gradientStops = intensityGradientSmooth.map((s) => ({
|
|
|
|
+ value: s.value,
|
|
|
|
+ rgb: hexToRgb(s.color),
|
|
|
|
+}))
|
|
|
|
+
|
|
/** 线性插值 */
|
|
/** 线性插值 */
|
|
-function lerp(a: number, b: number, t: number) {
|
|
|
|
|
|
+const lerp = (a: number, b: number, t: number) => {
|
|
return a + (b - a) * t
|
|
return a + (b - a) * t
|
|
}
|
|
}
|
|
|
|
|
|
@@ -712,7 +725,7 @@ function lerp(a: number, b: number, t: number) {
|
|
* @param value01 已在外部归一化到 0–1 的强度
|
|
* @param value01 已在外部归一化到 0–1 的强度
|
|
* @returns RGB (0–1)
|
|
* @returns RGB (0–1)
|
|
*/
|
|
*/
|
|
-function getInterpolatedColor(value01: number): RgbColor {
|
|
|
|
|
|
+const getInterpolatedColor = (value01: number): RgbColor => {
|
|
const v = Math.min(Math.max(value01, 0), 1) // clamp 0–1
|
|
const v = Math.min(Math.max(value01, 0), 1) // clamp 0–1
|
|
|
|
|
|
// 落在哪个区间
|
|
// 落在哪个区间
|
|
@@ -732,62 +745,21 @@ function getInterpolatedColor(value01: number): RgbColor {
|
|
return gradientStops[gradientStops.length - 1].rgb
|
|
return gradientStops[gradientStops.length - 1].rgb
|
|
}
|
|
}
|
|
|
|
|
|
-/**
|
|
|
|
- * 连接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)
|
|
|
|
|
|
+watch(
|
|
|
|
+ () => props.data,
|
|
|
|
+ (newData) => {
|
|
|
|
+ if (newData && newData.Payload && newData.Payload.RawPoints) {
|
|
|
|
+ updatePointCloud(newData.Payload.RawPoints)
|
|
|
|
+ state.isLoading = false
|
|
}
|
|
}
|
|
- }
|
|
|
|
-
|
|
|
|
- // 连接关闭回调
|
|
|
|
- ws.onclose = () => {
|
|
|
|
- console.log('WebSocket已断开,尝试重连...')
|
|
|
|
- loadingText.value = '连接断开,尝试重连...'
|
|
|
|
- isLoading.value = true
|
|
|
|
- setTimeout(connectWebSocket, 3000)
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- // 错误处理回调
|
|
|
|
- ws.onerror = (error) => {
|
|
|
|
- console.error('WebSocket错误:', error)
|
|
|
|
- }
|
|
|
|
-}
|
|
|
|
|
|
+ },
|
|
|
|
+ { immediate: true, deep: true }
|
|
|
|
+)
|
|
|
|
|
|
/**
|
|
/**
|
|
* 动画循环
|
|
* 动画循环
|
|
*/
|
|
*/
|
|
-function animate() {
|
|
|
|
|
|
+const animate = () => {
|
|
// 取消上一帧动画
|
|
// 取消上一帧动画
|
|
if (animationId) {
|
|
if (animationId) {
|
|
cancelAnimationFrame(animationId)
|
|
cancelAnimationFrame(animationId)
|
|
@@ -818,7 +790,7 @@ function animate() {
|
|
* 窗口大小变化处理函数
|
|
* 窗口大小变化处理函数
|
|
* 自动调整到最佳视角,但不限制用户后续操作
|
|
* 自动调整到最佳视角,但不限制用户后续操作
|
|
*/
|
|
*/
|
|
-function onWindowResize() {
|
|
|
|
|
|
+const onWindowResize = () => {
|
|
if (!container.value || !renderer || !camera) return
|
|
if (!container.value || !renderer || !camera) return
|
|
|
|
|
|
const { offsetWidth: width, offsetHeight: height } = container.value
|
|
const { offsetWidth: width, offsetHeight: height } = container.value
|
|
@@ -849,7 +821,7 @@ function onWindowResize() {
|
|
/**
|
|
/**
|
|
* 清理Three.js资源
|
|
* 清理Three.js资源
|
|
*/
|
|
*/
|
|
-function cleanupThreeJs() {
|
|
|
|
|
|
+const cleanupThreeJs = () => {
|
|
// 取消动画循环
|
|
// 取消动画循环
|
|
if (animationId) {
|
|
if (animationId) {
|
|
cancelAnimationFrame(animationId)
|
|
cancelAnimationFrame(animationId)
|
|
@@ -886,7 +858,7 @@ const configPanelVisible = ref(false)
|
|
* 应用新的房间尺寸设置
|
|
* 应用新的房间尺寸设置
|
|
* 重新创建房间墙壁和地板,并重置视图
|
|
* 重新创建房间墙壁和地板,并重置视图
|
|
*/
|
|
*/
|
|
-function applyRoomDimensions() {
|
|
|
|
|
|
+const applyRoomDimensions = () => {
|
|
if (!scene) return
|
|
if (!scene) return
|
|
|
|
|
|
// 移除现有的房间墙壁
|
|
// 移除现有的房间墙壁
|
|
@@ -915,14 +887,35 @@ function applyRoomDimensions() {
|
|
resetView()
|
|
resetView()
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+/**
|
|
|
|
+ * 点击信息面板之前的区域,关闭房间尺寸设置
|
|
|
|
+ * @param event 点击事件
|
|
|
|
+ */
|
|
|
|
+const handleClickOutside = (event: MouseEvent) => {
|
|
|
|
+ // 获取overlay和configPanel元素
|
|
|
|
+ const overlayElement = document.querySelector('.overlay') as HTMLElement
|
|
|
|
+ const configPanelElement = document.querySelector('.config-panel') as HTMLElement
|
|
|
|
+
|
|
|
|
+ // 检查点击是否发生在overlay或configPanel外部
|
|
|
|
+ if (configPanelVisible.value && overlayElement && configPanelElement) {
|
|
|
|
+ // 检查点击目标是否是overlay或其后代元素
|
|
|
|
+ const isClickInOverlay = overlayElement.contains(event.target as Node)
|
|
|
|
+
|
|
|
|
+ // 如果点击在overlay之外,则关闭配置面板
|
|
|
|
+ if (!isClickInOverlay) {
|
|
|
|
+ configPanelVisible.value = false
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
// 组件挂载时初始化
|
|
// 组件挂载时初始化
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
nextTick().then(() => {
|
|
nextTick().then(() => {
|
|
initThreeJs().then((success) => {
|
|
initThreeJs().then((success) => {
|
|
if (success) {
|
|
if (success) {
|
|
- connectWebSocket()
|
|
|
|
animate()
|
|
animate()
|
|
window.addEventListener('resize', onWindowResize)
|
|
window.addEventListener('resize', onWindowResize)
|
|
|
|
+ document.addEventListener('click', handleClickOutside)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
@@ -930,13 +923,9 @@ onMounted(() => {
|
|
|
|
|
|
// 组件卸载时清理
|
|
// 组件卸载时清理
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
- if (ws) {
|
|
|
|
- ws.close()
|
|
|
|
- ws = null
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
cleanupThreeJs()
|
|
cleanupThreeJs()
|
|
window.removeEventListener('resize', onWindowResize)
|
|
window.removeEventListener('resize', onWindowResize)
|
|
|
|
+ document.removeEventListener('click', handleClickOutside)
|
|
})
|
|
})
|
|
</script>
|
|
</script>
|
|
|
|
|