VSLAM_MIGRATION_PLAN.md 34 KB

建图预览功能移植方案

📋 项目概述

robot_map_editor 的 VSLAM 建图预览功能移植到 pns-web 项目中。

源项目: robot_map_editor (React + Redux)
目标项目: pns-web (Vue 2 + Vuex)


🎯 核心功能需求

1. 实时 3D 点云渲染

  • ✅ 使用 Three.js + Potree 渲染关键帧点云
  • ✅ 支持百万级点云数据显示
  • ✅ 点云高度颜色映射(蓝→绿→黄→红)
  • ✅ 视锥剔除优化(只渲染可见区域)
  • ✅ 点云稀释算法(最多显示100帧)

2. 实时机器人位姿显示

  • ✅ MQTT 订阅位姿信息
  • ✅ 3D 机器人模型显示(VRML/GLTF/OBJ)
  • ✅ 5 种视角模式:俯视、第三人称、第一人称、当前视角、自由视角

3. 交互功能

  • ✅ 引导模式(点击地面发送目标点)
  • ✅ 可视化对象检测显示(3D 边界框)
  • ✅ 规划轨迹实时显示
  • ✅ 建图过程回放
  • ✅ 手动控制(摇杆)

4. 地图优化

  • ✅ 闭环检测与自动地图修正
  • ✅ 路径轴线显示
  • ✅ 动态地面网格扩展

📂 目录结构规划

pns-web/
├── src/
│   ├── views/map/vslam/
│   │   ├── index.vue                    # 主页面(对应 VisualSlam/index.js)
│   │   ├── components/
│   │   │   ├── VSlamView.vue            # 核心 3D 渲染组件
│   │   │   ├── VSlamToolbar.vue         # 顶部工具栏
│   │   │   ├── VSlamControlPanel.vue    # 右侧控制面板
│   │   │   ├── ManualControl.vue        # 摇杆控制
│   │   │   └── MarkList.vue             # 标记列表
│   │   └── utils/
│   │       ├── CreateMesh.js            # 3D 对象创建工具
│   │       ├── Utils.js                 # 点云生成和颜色映射
│   │       └── IntersectPointsMesh.js   # 视锥内点云管理
│   │
│   ├── api/map/
│   │   └── vslam.js                     # VSLAM API 接口封装
│   │
│   ├── store/modules/
│   │   └── vslam.js                     # Vuex 状态管理模块
│   │
│   └── workers/
│       ├── StatisticsWorker.js          # 统计信息 Worker
│       ├── KeyframeWorker.js            # 点云数据 Worker
│       └── KeyframeTransWorker.js       # 变换矩阵 Worker
│
├── public/
│   └── static_assets/
│       ├── proto/                       # Protobuf 定义文件
│       │   ├── pointcloud_pb.js
│       │   └── transform_pb.js
│       └── models/                      # 机器人 3D 模型
│           ├── Robot.wrl
│           ├── JQG.glb
│           └── drone_costum.obj

🔧 技术栈映射

功能模块 robot_map_editor (源) pns-web (目标) 处理方式
UI 框架 React + Material-UI Vue 2 + Element UI 重写组件
状态管理 Redux Vuex 改写 Actions/Mutations
3D 渲染 Three.js + Potree Three.js + Potree 直接迁移
HTTP 请求 Axios Axios 适配 API 前缀
MQTT 通信 MQTT.js 已有 MQTT 组件 复用组件
数据格式 Protobuf Protobuf 安装 google-protobuf
Web Workers 原生 Worker 原生 Worker 直接迁移

📦 依赖安装

需要新增的 npm 包:

npm install three@0.132.2 --save
npm install google-protobuf@3.21.4 --save
npm install vue-joystick-component@6.2.1 --save  # 摇杆控制

已有依赖(无需安装):

  • ✅ axios (0.28.1)
  • ✅ mqtt (4.2.1)
  • ✅ ol (6.15.1) - 用于 2D 地图
  • ✅ Potree 库(已在 public/static_assets/libs/potree)

🔄 核心组件映射关系

1. 主页面组件

robot_map_editor pns-web 说明
pages/VisualSlam/index.js views/map/vslam/index.vue 主容器页面
components/VSlamHeaderToolBar views/map/vslam/components/VSlamToolbar.vue 顶部工具栏
components/Views/VSlamView views/map/vslam/components/VSlamView.vue 核心渲染组件
components/VSlamClosureWindow views/map/vslam/components/VSlamControlPanel.vue 控制面板

2. Redux → Vuex 映射

Redux State (vslamReducer.js)

{
  mapName: '',
  currentView: 5,
  bootModeIsCheck: false,
  runningState: false,
  robotPosition: {},
  robotVisiable: false,
  replayState: 0,
  fullScreen: {},
  uiConfig: {}
}

Vuex Store (vslam.js)

{
  state: { ... },       // 同上
  mutations: {          // 对应 Redux Reducers
    SET_MAP_NAME,
    SET_CURRENT_VIEW,
    SET_BOOT_MODE,
    // ...
  },
  actions: {            // 对应 Redux Actions
    updateMapName,
    changeView,
    toggleBootMode,
    // ...
  }
}

3. API 接口映射

HTTP API 端点(需要在后端实现):

// 统计信息
GET /v1/vslam/statistics?map={mapName}
返回: { keyframes: 数量, closures: 闭环数, running: true/false }

// 关键帧点云数据
GET /v1/vslam/keyframe/cloud?map={map}&idx={index}
格式: Protobuf (PointcloudType)

// 变换矩阵
GET /v1/vslam/keyframe/trans?map={map}&idx={index}
格式: Protobuf (Transform)

MQTT 主题订阅:

// 机器人位姿
Topic: /exploration/localization/pose
数据: { pose: { xyz: [x,y,z], rpy: [r,p,y] } }

// 可视化对象
Topic: /visualization/object
数据: [ { id, name, type, points, scale, color } ]

// 规划轨迹
Topic: /exploration/planning/trajectory
数据: [ [x1,y1,z1], [x2,y2,z2], ... ]

🚀 实施步骤详解

Phase 1: 基础架构搭建(第1-2天)

任务 1: 创建主页面组件

<!-- src/views/map/vslam/index.vue -->
<template>
  <div class="vslam-container">
    <VSlamToolbar 
      :map-name="mapName"
      :running="runningState"
      @back="handleBack"
    />
    <div class="vslam-content">
      <VSlamView 
        :map-name="mapName"
        :current-view="currentView"
        :boot-mode="bootModeIsCheck"
      />
      <VSlamControlPanel 
        v-model="panelVisible"
        :current-view="currentView"
        :boot-mode="bootModeIsCheck"
        @view-change="handleViewChange"
        @boot-toggle="handleBootToggle"
      />
    </div>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex'
import VSlamToolbar from './components/VSlamToolbar'
import VSlamView from './components/VSlamView'
import VSlamControlPanel from './components/VSlamControlPanel'

export default {
  name: 'VSlamPreview',
  components: { VSlamToolbar, VSlamView, VSlamControlPanel },
  computed: {
    ...mapState('vslam', ['mapName', 'currentView', 'bootModeIsCheck', 'runningState']),
  },
  methods: {
    ...mapActions('vslam', ['updateMapName', 'setCurrentView', 'setBootMode']),
  }
}
</script>

任务 2: 创建 Vuex Store 模块

// src/store/modules/vslam.js
const state = {
  mapName: '',
  currentView: 5, // 1:俯视 2:第三人称 3:第一人称 4:当前视角 5:自由视角
  bootModeIsCheck: false,
  runningState: false,
  robotPosition: { x: 0, y: 0, z: 0 },
  robotVisiable: false,
  replayState: 0,
  fullScreen: { name: 'webPage', state: false },
  uiConfig: {
    robotVisiable: true,
    axesHelper: false,
    modelName: 'robot',
    offset: [0, 0, 0]
  }
}

const mutations = {
  SET_MAP_NAME(state, mapName) {
    state.mapName = mapName
  },
  SET_CURRENT_VIEW(state, view) {
    state.currentView = view
  },
  SET_BOOT_MODE(state, mode) {
    state.bootModeIsCheck = mode
    if (mode) state.currentView = 1 // 引导模式强制俯视图
  },
  SET_RUNNING_STATE(state, running) {
    state.runningState = running
  },
  SET_ROBOT_POSITION(state, position) {
    state.robotPosition = position
  },
  SET_ROBOT_VISIABLE(state, visible) {
    state.robotVisiable = visible
  },
  // ... 其他 mutations
}

const actions = {
  updateMapName({ commit }, mapName) {
    commit('SET_MAP_NAME', mapName)
  },
  setCurrentView({ commit }, view) {
    commit('SET_CURRENT_VIEW', view)
  },
  setBootMode({ commit }, mode) {
    commit('SET_BOOT_MODE', mode)
  },
  // ... 其他 actions
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

注册到主 store:

// src/store/index.js
import vslam from './modules/vslam'

export default new Vuex.Store({
  modules: {
    // ... 其他模块
    vslam
  }
})

Phase 2: 核心 3D 渲染组件(第3-5天)

任务 3: 创建 VSlamView 核心组件

<!-- src/views/map/vslam/components/VSlamView.vue -->
<template>
  <div 
    id="vslam-view-3d" 
    ref="view3d"
    class="vslam-view"
  ></div>
</template>

<script>
import * as THREE from 'three'
import { mapState, mapMutations } from 'vuex'
import Utils from '../utils/Utils'
import {
  CreateGroundMesh,
  CreateFlowmark,
  CreateObjectBox,
  CreateShapMesh,
  CreatePlaybackeMesh
} from '../utils/CreateMesh'
import createIntersectPointsMesh from '../utils/IntersectPointsMesh'

export default {
  name: 'VSlamView',
  props: {
    mapName: {
      type: String,
      required: true
    },
    currentView: {
      type: Number,
      default: 5
    },
    bootMode: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      viewerReady: false,
      robotObj: null,
      modelOffset: [0, 0, 0],
      currentCamera: new THREE.Vector3(0, 0, 0),
      currentPlane: null,
      pointsGroup: new THREE.Group(),
      newPointsGroup: new THREE.Group(),
      routeGroup: new THREE.Group(),
      cloudArry: [],
      gTransArry: [],
      boxWrap: [],
      showIndexs: [],
      intersectingIndexs: [],
      // Workers
      statisticsWorker: null,
      keyframeWorker: null,
      keyframeTransWorker: null,
    }
  },
  computed: {
    ...mapState('vslam', ['robotPosition', 'uiConfig'])
  },
  mounted() {
    this.initViewer()
    this.initWorkers()
    this.startFetchData()
  },
  beforeDestroy() {
    this.cleanupWorkers()
    this.cleanupScene()
  },
  methods: {
    ...mapMutations('vslam', ['SET_RUNNING_STATE', 'SET_ROBOT_VISIABLE']),
    
    // 初始化 Potree Viewer
    initViewer() {
      if (!window.Potree) {
        console.error('Potree library not loaded!')
        return
      }
      
      const container = this.$refs.view3d
      window.viewer = new Potree.Viewer(container)
      
      // 配置场景
      const hemiLight = new THREE.HemisphereLight(0xffffff, 0x080820, 20)
      viewer.renderer.sortObjects = false
      viewer.scene.scene.add(hemiLight)
      
      // 设置相机
      if (viewer.scene.view) {
        viewer.scene.view.position.copy(new THREE.Vector3(0, 0, 45))
        viewer.scene.view.lookAt(new THREE.Vector3(0, 0, 0))
      }
      
      viewer.setEDLEnabled(false)
      viewer.setFOV(60)
      viewer.setPointBudget(1_000_000)
      viewer.setBackground('black')
      
      // 添加点云组
      viewer.scene.scene.add(this.pointsGroup)
      viewer.scene.scene.add(this.newPointsGroup)
      if (this.uiConfig.axesHelper) {
        viewer.scene.scene.add(this.routeGroup)
      }
      
      // 创建地面网格
      this.createGroundMesh()
      
      // 加载机器人模型
      if (this.uiConfig.robotVisiable) {
        this.loadRobotModel()
      }
      
      this.viewerReady = true
    },
    
    // 初始化 Web Workers
    initWorkers() {
      // 统计信息 Worker
      this.statisticsWorker = new Worker('/workers/StatisticsWorker.js')
      this.statisticsWorker.onmessage = this.handleStatisticsData
      
      // 点云数据 Worker
      this.keyframeWorker = new Worker('/workers/KeyframeWorker.js')
      this.keyframeWorker.onmessage = this.handleKeyframeData
      
      // 变换矩阵 Worker
      this.keyframeTransWorker = new Worker('/workers/KeyframeTransWorker.js')
      this.keyframeTransWorker.onmessage = this.handleTransformData
    },
    
    // 开始数据获取循环
    startFetchData() {
      // 启动统计信息轮询
      this.statisticsWorker.postMessage({
        action: 'startPolling',
        url: `/pns/v1/vslam/statistics?map=${this.mapName}`
      })
      
      // 循环获取点云和变换矩阵
      this.fetchLoop = setInterval(() => {
        this.fetchCloud()
        this.fetchTrans()
      }, 1)
    },
    
    // 处理统计信息
    handleStatisticsData(event) {
      const info = event.data
      const currentKeyFramesCnt = info.keyframes
      
      // 更新运行状态
      this.SET_RUNNING_STATE(info.running)
      
      // 计算稀释距离
      let distance = 1
      if (currentKeyFramesCnt > 100) {
        distance = Math.ceil(currentKeyFramesCnt / 100)
      }
      
      // 生成显示索引数组
      const indexs = this.generateArray(distance, currentKeyFramesCnt)
      
      // 更新点云显示
      this.updatePointCloudDisplay(indexs)
      
      // 显示机器人模型
      if (this.robotObj && !this.robotObj.visible) {
        this.robotObj.visible = true
        this.SET_ROBOT_VISIABLE(true)
      }
    },
    
    // 生成显示索引数组
    generateArray(distance, count) {
      const arr = []
      for (let i = 0; i < count; i += distance) {
        arr.push(i)
      }
      if (arr[arr.length - 1] > count) {
        arr.pop()
      }
      return arr
    },
    
    // 更新点云显示
    updatePointCloudDisplay(newIndexs) {
      const pointsMeshs = this.pointsGroup.children
      
      // 找出需要删除的点云
      const removeIndexs = this.showIndexs.filter(
        value => !newIndexs.includes(value)
      )
      
      // 删除点云
      for (const idx of removeIndexs) {
        const mesh = pointsMeshs.find(m => m.transIndex === idx)
        if (mesh) {
          mesh.geometry.dispose()
          mesh.material.dispose()
          this.pointsGroup.remove(mesh)
        }
      }
      
      // 找出需要添加的点云
      const addIndexs = newIndexs.filter(
        value => !this.showIndexs.includes(value)
      )
      
      // 添加点云
      for (const idx of addIndexs) {
        if (this.cloudArry[idx] && this.gTransArry[idx]) {
          const { object } = Utils.genParticles(
            this.cloudArry[idx].pointsList,
            this.gTransArry[idx]
          )
          object.transIndex = idx
          this.pointsGroup.add(object)
        }
      }
      
      this.showIndexs = newIndexs
    },
    
    // 获取点云数据
    fetchCloud() {
      if (this.cloudArry.length < this.currentKeyFramesCnt) {
        this.keyframeWorker.postMessage({
          url: `/pns/v1/vslam/keyframe/cloud?map=${this.mapName}&idx=${this.cloudArry.length}`
        })
      }
    },
    
    // 获取变换矩阵
    fetchTrans() {
      if (this.gTransArry.length < this.cloudArry.length) {
        this.keyframeTransWorker.postMessage({
          url: `/pns/v1/vslam/keyframe/trans?map=${this.mapName}&idx=${this.gTransArry.length}`
        })
      }
    },
    
    // 加载机器人模型
    loadRobotModel() {
      // 根据配置加载不同格式的模型
      const modelName = this.uiConfig.modelName
      
      if (modelName === 'drone') {
        // OBJ + MTL 格式
        // ... 加载代码
      } else if (modelName === 'dog') {
        // GLTF 格式
        // ... 加载代码
      } else {
        // VRML 格式
        const loader = new VRMLLoader()
        loader.load('/static_assets/models/Robot.wrl', (object) => {
          this.robotObj = object
          this.robotObj.visible = false
          viewer.scene.scene.add(this.robotObj)
        })
      }
    },
    
    // 创建地面网格
    createGroundMesh() {
      this.groundMeshFunc = new CreateGroundMesh()
      this.currentPlane = this.groundMeshFunc.create()
      viewer.scene.scene.add(this.currentPlane)
      
      // 添加鼠标点击事件(用于引导模式)
      const viewer3d = document.getElementById('vslam-view-3d')
      viewer3d.addEventListener('pointerdown', this.onPointerDown)
      viewer3d.addEventListener('pointerup', this.onPointerUp)
    },
    
    // 清理 Workers
    cleanupWorkers() {
      if (this.statisticsWorker) {
        this.statisticsWorker.postMessage({ action: 'stopPolling' })
        this.statisticsWorker.terminate()
      }
      if (this.keyframeWorker) {
        this.keyframeWorker.terminate()
      }
      if (this.keyframeTransWorker) {
        this.keyframeTransWorker.terminate()
      }
      if (this.fetchLoop) {
        clearInterval(this.fetchLoop)
      }
    },
    
    // 清理场景
    cleanupScene() {
      // 释放点云资源
      this.pointsGroup.children.forEach(mesh => {
        mesh.geometry.dispose()
        mesh.material.dispose()
      })
      this.newPointsGroup.children.forEach(mesh => {
        mesh.geometry.dispose()
        mesh.material.dispose()
      })
      
      // 清理数组
      this.cloudArry = []
      this.gTransArry = []
    }
  }
}
</script>

<style scoped>
.vslam-view {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
}
</style>

Phase 3: Web Workers 实现(第6天)

任务 4: 创建 3 个 Web Workers

1. StatisticsWorker.js

// public/workers/StatisticsWorker.js
let pollingIntervalId = null

self.onmessage = function(event) {
  const action = event.data.action
  
  if (action === 'startPolling') {
    const { url, interval = 1000 } = event.data
    
    if (pollingIntervalId) {
      clearInterval(pollingIntervalId)
    }
    
    pollingIntervalId = setInterval(() => {
      fetch(url)
        .then(response => response.json())
        .then(data => {
          self.postMessage(data)
        })
        .catch(error => {
          console.error('Statistics fetch error:', error)
        })
    }, interval)
    
  } else if (action === 'stopPolling') {
    if (pollingIntervalId) {
      clearInterval(pollingIntervalId)
      pollingIntervalId = null
    }
  }
}

2. KeyframeWorker.js

// public/workers/KeyframeWorker.js
self.onmessage = function(event) {
  const { url } = event.data
  
  fetch(url, {
    method: 'GET',
    responseType: 'arraybuffer'
  })
  .then(response => response.arrayBuffer())
  .then(rsp => {
    const arry = new Uint8Array(rsp)
    self.postMessage(arry)
  })
  .catch(error => {
    console.error('Keyframe fetch error:', error)
  })
}

3. KeyframeTransWorker.js

// public/workers/KeyframeTransWorker.js
self.onmessage = function(event) {
  const { url } = event.data
  
  fetch(url, {
    method: 'GET',
    responseType: 'arraybuffer'
  })
  .then(response => response.arrayBuffer())
  .then(rsp => {
    const arry = new Uint8Array(rsp)
    self.postMessage(arry)
  })
  .catch(error => {
    console.error('Transform fetch error:', error)
  })
}

Phase 4: 工具类实现(第7天)

任务 5 & 6: 创建 CreateMesh.js 和 Utils.js

CreateMesh.js - 完整复制 robot_map_editor 的 CreateMesh.js
Utils.js - 完整复制 robot_map_editor 的 Utils.js
IntersectPointsMesh.js - 完整复制 createIntersectPointsMesh.js

这些文件是纯 JavaScript,无框架依赖,可直接复制。


Phase 5: Protobuf 配置(第8天)

任务 7: 配置 Protobuf

1. 安装依赖

npm install google-protobuf@3.21.4 --save

2. 复制 proto 文件

# 复制 .proto 源文件
cp robot_map_editor/proto/*.proto pns-web/proto/

# 复制已生成的 *_pb.js 文件
cp robot_map_editor/src/datastruct/proto/*.js pns-web/src/datastruct/proto/

3. 在 Vue 组件中使用

// src/api/map/vslam.js
import kfcloud from '@/datastruct/proto/pointcloud_pb'
import kftrans from '@/datastruct/proto/transform_pb'

// 解析点云数据
export function parsePointcloudData(arrayBuffer) {
  const arry = new Uint8Array(arrayBuffer)
  return kfcloud.PointcloudType.deserializeBinary(arry).toObject()
}

// 解析变换矩阵
export function parseTransformData(arrayBuffer) {
  const arry = new Uint8Array(arrayBuffer)
  return kftrans.Transform.deserializeBinary(arry).toObject()
}

Phase 6: API 接口封装(第9天)

任务 8: 创建 VSlamAPI.js

// src/api/map/vslam.js
import request from '@/utils/request'
import kfcloud from '@/datastruct/proto/pointcloud_pb'
import kftrans from '@/datastruct/proto/transform_pb'

// 获取 VSLAM 统计信息
export function getVSlamStatistics(mapName) {
  return request({
    url: '/v1/vslam/statistics',
    baseURL: '/pns',
    method: 'get',
    params: { map: mapName }
  })
}

// 获取关键帧点云数据(返回解析后的对象)
export function getKeyframePointcloud(mapName, idx) {
  return request({
    url: '/v1/vslam/keyframe/cloud',
    baseURL: '/pns',
    method: 'get',
    params: { map: mapName, idx },
    responseType: 'arraybuffer',
    headers: {
      'Content-Type': 'application/x-protobuf',
      'Accept': 'application/x-protobuf'
    }
  }).then(response => {
    const arry = new Uint8Array(response)
    return kfcloud.PointcloudType.deserializeBinary(arry).toObject()
  })
}

// 获取关键帧变换矩阵(返回解析后的对象)
export function getKeyframeTrans(mapName, idx) {
  return request({
    url: '/v1/vslam/keyframe/trans',
    baseURL: '/pns',
    method: 'get',
    params: { map: mapName, idx },
    responseType: 'arraybuffer',
    headers: {
      'Content-Type': 'application/x-protobuf',
      'Accept': 'application/x-protobuf'
    }
  }).then(response => {
    const arry = new Uint8Array(response)
    return kftrans.Transform.deserializeBinary(arry).toObject()
  })
}

// 获取闭环详情
export function getClosureDetails(mapName, idx) {
  return request({
    url: '/v1/vslam/closure/details',
    baseURL: '/pns',
    method: 'get',
    params: { map: mapName, idx }
  })
}

// URL 生成器(用于 Workers)
export function urlVSlamStatistics(mapName) {
  return `/pns/v1/vslam/statistics?map=${mapName}`
}

export function urlKeyframePointcloud(mapName, idx) {
  return `/pns/v1/vslam/keyframe/cloud?map=${mapName}&idx=${idx}`
}

export function urlKeyframeTrans(mapName, idx) {
  return `/pns/v1/vslam/keyframe/trans?map=${mapName}&idx=${idx}`
}

Phase 7: UI 组件实现(第10-11天)

任务 9 & 10: 创建控制面板和工具栏

VSlamControlPanel.vue

<template>
  <el-drawer
    :visible.sync="visible"
    direction="rtl"
    size="320px"
    :with-header="false"
    :modal="false"
    class="vslam-control-panel"
  >
    <div class="panel-content">
      <!-- 操作区域 -->
      <el-collapse v-model="activeNames" accordion>
        <el-collapse-item title="视角控制" name="view">
          <div class="control-section">
            <el-form label-width="100px" size="small">
              <el-form-item label="视角模式">
                <el-select 
                  :value="currentView" 
                  @change="handleViewChange"
                  :disabled="bootMode"
                  style="width: 100%"
                >
                  <el-option label="自由视角" :value="5" />
                  <el-option label="俯视图" :value="1" :disabled="!running" />
                  <el-option label="第三人称" :value="2" :disabled="!running" />
                  <el-option label="第一人称" :value="3" :disabled="!running" />
                  <el-option label="当前视角" :value="4" :disabled="!running" />
                </el-select>
              </el-form-item>
              
              <el-form-item label="引导模式">
                <el-switch 
                  :value="bootMode"
                  @change="handleBootToggle"
                  :disabled="!running"
                />
              </el-form-item>
              
              <el-form-item label="手动控制" v-if="showManualControl">
                <el-switch 
                  :value="manualControl"
                  @change="handleManualToggle"
                />
              </el-form-item>
              
              <el-form-item label="实时视频" v-if="showRealtimeVideo">
                <el-switch 
                  :value="realtimeVideo"
                  @change="handleVideoToggle"
                />
              </el-form-item>
            </el-form>
          </div>
        </el-collapse-item>
        
        <el-collapse-item title="回放控制" name="replay">
          <div class="control-section">
            <el-button 
              type="primary" 
              icon="el-icon-video-play"
              @click="handleReplay"
              style="width: 100%"
            >
              播放建图过程
            </el-button>
          </div>
        </el-collapse-item>
      </el-collapse>
      
      <!-- 机器人位置信息 -->
      <div class="robot-info" v-if="robotVisible">
        <el-divider>机器人位置</el-divider>
        <div class="info-grid">
          <div class="info-item">
            <span class="label">X:</span>
            <span class="value">{{ robotPosition.x || 0 }} m</span>
          </div>
          <div class="info-item">
            <span class="label">Y:</span>
            <span class="value">{{ robotPosition.y || 0 }} m</span>
          </div>
          <div class="info-item">
            <span class="label">Z:</span>
            <span class="value">{{ robotPosition.z || 0 }} m</span>
          </div>
        </div>
      </div>
    </div>
    
    <!-- 手动控制摇杆 -->
    <ManualControl v-if="manualControl" />
  </el-drawer>
</template>

<script>
import { mapState, mapMutations } from 'vuex'
import ManualControl from './ManualControl'

export default {
  name: 'VSlamControlPanel',
  components: { ManualControl },
  props: {
    value: Boolean, // v-model
    currentView: Number,
    bootMode: Boolean,
    running: Boolean,
    showManualControl: {
      type: Boolean,
      default: true
    },
    showRealtimeVideo: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      activeNames: ['view'],
      manualControl: false,
      realtimeVideo: false
    }
  },
  computed: {
    ...mapState('vslam', ['robotPosition', 'robotVisible']),
    visible: {
      get() { return this.value },
      set(val) { this.$emit('input', val) }
    }
  },
  methods: {
    handleViewChange(view) {
      this.$emit('view-change', view)
    },
    handleBootToggle(enabled) {
      this.$emit('boot-toggle', enabled)
    },
    handleManualToggle(enabled) {
      this.manualControl = enabled
      // 通过 MQTT 发送控制指令
      this.$mqtt.publish(`${this.$mqttPrefix}/joy/enable`, JSON.stringify({ enable: enabled }))
    },
    handleVideoToggle(enabled) {
      this.realtimeVideo = enabled
    },
    handleReplay() {
      this.$emit('replay')
    }
  }
}
</script>

<style scoped>
.vslam-control-panel {
  background: rgba(255, 255, 255, 0.95);
}

.panel-content {
  padding: 20px;
}

.control-section {
  padding: 10px 0;
}

.robot-info {
  margin-top: 20px;
}

.info-grid {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 10px;
}

.info-item {
  background: #f5f5f5;
  padding: 10px;
  border-radius: 4px;
  text-align: center;
}

.info-item .label {
  display: block;
  font-size: 12px;
  color: #999;
  margin-bottom: 5px;
}

.info-item .value {
  display: block;
  font-size: 16px;
  font-weight: bold;
  color: #333;
}
</style>

VSlamToolbar.vue

<template>
  <div class="vslam-toolbar">
    <el-breadcrumb separator="/">
      <el-breadcrumb-item :to="{ path: '/map/list' }">
        <i class="el-icon-s-home"></i> 首页
      </el-breadcrumb-item>
      <el-breadcrumb-item>{{ mapName }}</el-breadcrumb-item>
    </el-breadcrumb>
    
    <div class="toolbar-actions">
      <span class="robot-position" v-if="robotVisible">
        X: {{ robotPosition.x || 0 }}  
        Y: {{ robotPosition.y || 0 }}  
        Z: {{ robotPosition.z || 0 }}
      </span>
      
      <el-button 
        v-if="running"
        type="warning"
        size="small"
        icon="el-icon-plus"
        :loading="creatingSubmap"
        @click="handleCreateSubmap"
      >
        创建子地图
      </el-button>
      
      <el-button 
        type="primary"
        size="small"
        icon="el-icon-refresh"
        @click="handleRefresh"
      >
        刷新
      </el-button>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'VSlamToolbar',
  props: {
    mapName: String,
    running: Boolean
  },
  data() {
    return {
      creatingSubmap: false
    }
  },
  computed: {
    ...mapState('vslam', ['robotPosition', 'robotVisible'])
  },
  methods: {
    handleCreateSubmap() {
      this.creatingSubmap = true
      // 实现子地图创建逻辑
      this.$mqtt.publish(`${this.$mqttPrefix}/ability/function/action/exec/call`, JSON.stringify({
        function: 'ASM.map_slam.stop',
        args: []
      }))
      
      setTimeout(() => {
        this.creatingSubmap = false
      }, 3000)
    },
    handleRefresh() {
      this.$emit('refresh')
    }
  }
}
</script>

<style scoped>
.vslam-toolbar {
  height: 60px;
  padding: 0 20px;
  background: #fff;
  border-bottom: 1px solid #e8e8e8;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.toolbar-actions {
  display: flex;
  align-items: center;
  gap: 15px;
}

.robot-position {
  font-size: 14px;
  color: #666;
}

.robot-position span {
  margin-right: 15px;
}
</style>

Phase 8: MQTT 集成(第12天)

任务 11: 集成 MQTT 通信

在主页面中订阅 MQTT 主题:

<!-- src/views/map/vslam/index.vue -->
<template>
  <div class="vslam-container">
    <!-- ... UI 组件 ... -->
    
    <!-- MQTT 组件 -->
    <MqttComp 
      ref="mqtt" 
      :topics="mqttTopics" 
      @message-received="handleMqttMessage" 
    />
  </div>
</template>

<script>
import MqttComp from '@/components/Mqtt/mqttComp'

export default {
  components: { MqttComp },
  data() {
    return {
      mqttTopics: [
        { topic: `${this.$mqttPrefix}/exploration/localization/pose` },
        { topic: `${this.$mqttPrefix}/visualization/object` },
        { topic: `${this.$mqttPrefix}/exploration/planning/trajectory` }
      ]
    }
  },
  methods: {
    handleMqttMessage(topic, message) {
      try {
        const data = JSON.parse(message)
        
        if (topic.endsWith('/localization/pose')) {
          this.handlePoseUpdate(data)
        } else if (topic.endsWith('/visualization/object')) {
          this.handleVisualizationObject(data)
        } else if (topic.endsWith('/planning/trajectory')) {
          this.handlePlanningTrajectory(data)
        }
      } catch (err) {
        console.error('MQTT message parse error:', err)
      }
    },
    
    handlePoseUpdate(data) {
      // 更新机器人位置
      this.$store.commit('vslam/SET_ROBOT_POSITION', {
        x: data.pose.xyz[0].toFixed(2),
        y: data.pose.xyz[1].toFixed(2),
        z: data.pose.xyz[2].toFixed(2)
      })
      
      // 通知 VSlamView 组件更新机器人位姿
      this.$refs.vslamView?.updateRobotPose(data)
    },
    
    handleVisualizationObject(data) {
      // 显示检测对象的 3D 边界框
      this.$refs.vslamView?.updateVisualizationObjects(data)
    },
    
    handlePlanningTrajectory(data) {
      // 显示规划轨迹
      this.$refs.vslamView?.updatePlanningTrajectory(data)
    }
  }
}
</script>

Phase 9: 路由配置(第13天)

任务 12: 添加路由

// src/router/index.js
{
  path: '/map',
  component: Layout,
  children: [
    // ... 其他路由
    {
      path: 'vslam/:mapName',
      name: 'VSlamPreview',
      component: () => import('@/views/map/vslam/index'),
      meta: { 
        title: 'VSLAM 建图预览', 
        icon: 'el-icon-view',
        noCache: true
      }
    }
  ]
}

使用方式:

// 从地图列表页跳转
this.$router.push({
  name: 'VSlamPreview',
  params: { mapName: 'my_map_001' }
})

🧪 测试验证(第14天)

任务 13: 测试和调试

测试清单:

  • [ ] 基础功能

    • 页面正常加载,无报错
    • Potree Viewer 正常初始化
    • 3D 场景渲染正常
  • [ ] 数据获取

    • 统计信息轮询正常
    • 点云数据获取正常
    • 变换矩阵获取正常
    • Protobuf 解析正确
  • [ ] 点云渲染

    • 点云正确显示
    • 颜色映射正确
    • 点云稀释算法生效
    • 视锥剔除生效
  • [ ] 机器人显示

    • 模型加载成功
    • 位姿更新正确
    • MQTT 通信正常
  • [ ] 视角控制

    • 5种视角模式切换正常
    • 相机跟随正确
    • 引导模式点击正常
  • [ ] 性能测试

    • 大规模点云(>1000帧)流畅度
    • 内存占用正常
    • CPU 占用合理

📝 注意事项

1. Potree 库引入

确保在 public/index.html 中引入 Potree:

<!-- public/index.html -->
<script src="<%= BASE_URL %>static_assets/libs/potree/potree/potree.js"></script>
<script src="<%= BASE_URL %>static_assets/libs/tween/Tween.js"></script>
<link rel="stylesheet" href="<%= BASE_URL %>static_assets/libs/potree/potree/potree.css">

2. Three.js 版本

必须使用 Three.js 0.132.2,与 Potree 兼容。

3. Web Workers 路径

Workers 需要放在 public/workers/ 目录下,通过绝对路径引用。

4. Protobuf 编译

如果修改了 .proto 文件,需要重新编译:

cd proto
protoc --js_out=import_style=commonjs,binary:../src/datastruct/proto pointcloud.proto
protoc --js_out=import_style=commonjs,binary:../src/datastruct/proto transform.proto

5. MQTT 主题前缀

确保使用正确的 MQTT 主题前缀(通过 this.$mqttPrefix 访问)。

6. 后端 API 支持

确保后端实现了以下 API:

  • /v1/vslam/statistics
  • /v1/vslam/keyframe/cloud
  • /v1/vslam/keyframe/trans
  • /v1/vslam/closure/details

🎨 样式规范

使用 Element UI 主题色:

  • 主色:#409EFF
  • 成功:#67C23A
  • 警告:#E6A23C
  • 危险:#F56C6C

📚 参考文档


🔗 相关链接

  • 源项目:robot_map_editor
  • 目标项目:pns-web
  • 移植分支:feature/vslam-preview

📞 技术支持

如有问题,请联系项目负责人或提交 Issue。

移植完成预计时间:14天


最后更新:2025-11-06