将 robot_map_editor 的 VSLAM 建图预览功能移植到 pns-web 项目中。
源项目: robot_map_editor (React + Redux)
目标项目: pns-web (Vue 2 + Vuex)
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 install three@0.132.2 --save
npm install google-protobuf@3.21.4 --save
npm install vue-joystick-component@6.2.1 --save # 摇杆控制
| 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 |
控制面板 |
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,
// ...
}
}
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], ... ]
<!-- 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>
// 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
}
})
<!-- 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>
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)
})
}
CreateMesh.js - 完整复制 robot_map_editor 的 CreateMesh.js
Utils.js - 完整复制 robot_map_editor 的 Utils.js
IntersectPointsMesh.js - 完整复制 createIntersectPointsMesh.js
这些文件是纯 JavaScript,无框架依赖,可直接复制。
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()
}
// 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}`
}
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>
在主页面中订阅 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>
// 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' }
})
测试清单:
[ ] 基础功能
[ ] 数据获取
[ ] 点云渲染
[ ] 机器人显示
[ ] 视角控制
[ ] 性能测试
确保在 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">
必须使用 Three.js 0.132.2,与 Potree 兼容。
Workers 需要放在 public/workers/ 目录下,通过绝对路径引用。
如果修改了 .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
确保使用正确的 MQTT 主题前缀(通过 this.$mqttPrefix 访问)。
确保后端实现了以下 API:
/v1/vslam/statistics/v1/vslam/keyframe/cloud/v1/vslam/keyframe/trans/v1/vslam/closure/details使用 Element UI 主题色:
#409EFF#67C23A#E6A23C#F56C6C如有问题,请联系项目负责人或提交 Issue。
移植完成预计时间:14天
最后更新:2025-11-06