feat(dashboard1): 首页地图支持三维点云展示,机器人配置支持上传 PCD

- 设备管理机器人配置弹窗新增点云 PCD 上传,地址保存到 ros_car.mapPcdUrl
- 首页地图新增 2D/3D 切换,三维视图动态加载 three.js 渲染 PCD 点云
- 三维视图:按 z 直方图估算地面使小车与路径贴地、四轮小车模型、地面绘制巡检路径、放大预览支持 WASD 移动视角
- 地面按占用栅格生成连续平面,并剔除地面带内的点云,避免重复渲染
- PCD 解析支持 ascii/binary/binary_compressed,按流畅/标准/精细三档均匀抽稀
- 二维放大保留原拖拽平移与滚轮缩放
This commit is contained in:
2026-09-26 23:38:37 +08:00
parent 785a6b60aa
commit 3dc1a1d313
8 changed files with 1867 additions and 15 deletions

View File

@@ -29,6 +29,7 @@ import { getToken } from "@/utils/auth";
import { parseRosMapYaml } from "@/utils/rosMapYaml";
import { BASE_URL } from "@/utils/request";
import { getRecentAlertPhoto } from "../shared/alertPhotos";
import usePointCloud3D from "../hooks/usePointCloud3D";
// 地图上的机器人标记图标(设计稿 巡检机器人_03,8 倍图,放大时不易模糊)
import robotIcon from "@/assets/robot-marker-8x.png";
// 未配置地图时展示的默认隧道示意图
@@ -41,6 +42,15 @@ type DriveMode = "阿克曼" | "旋转" | "横移";
type CameraChannelKey = "white" | "infrared";
const cameraChannelKeys: CameraChannelKey[] = ["white", "infrared"];
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
// 地图视图模式:二维栅格地图 / 三维点云地图
type MapViewMode = "2d" | "3d";
// 三维点云质量档位:直接影响渲染点数与帧率
type PointCloudQuality = "low" | "normal" | "high";
const pointCloudQualityOptions: Array<{ value: PointCloudQuality; label: string; maxPoints: number }> = [
{ value: "low", label: "流畅", maxPoints: 300000 },
{ value: "normal", label: "标准", maxPoints: 800000 },
{ value: "high", label: "精细", maxPoints: 2000000 },
];
type ControlMessageType = "cmd_vel" | "ptz_control" | "lift_control";
type PtzAction = "up" | "down" | "left" | "right" | "stop";
@@ -57,6 +67,7 @@ interface RosCarRecord {
poseYaw?: number;
mapPgmUrl?: string;
mapYamlUrl?: string;
mapPcdUrl?: string;
cameraWebrtcUrl?: string;
cameraWebrtcUrl2?: string;
liftHeight?: number;
@@ -88,6 +99,7 @@ interface CarStatusPushData {
poseYaw?: number;
mapPgmUrl?: string;
mapYamlUrl?: string;
mapPcdUrl?: string;
status?: number;
signal?: string;
dockStatus?: string;
@@ -136,6 +148,7 @@ interface DeviceItem {
poseYaw?: number;
mapPgmUrl: string;
mapYamlUrl: string;
mapPcdUrl: string;
whiteLightUrl: string;
infraredUrl: string;
dockStatus?: string;
@@ -251,6 +264,7 @@ const emptyDevice: DeviceItem = {
poseYaw: undefined,
mapPgmUrl: "",
mapYamlUrl: "",
mapPcdUrl: "",
whiteLightUrl: "",
infraredUrl: "",
};
@@ -314,6 +328,7 @@ function normalizeDeviceRecord(car: RosCarRecord, index: number): DeviceItem {
poseYaw: car.poseYaw,
mapPgmUrl: car.mapPgmUrl || "",
mapYamlUrl: car.mapYamlUrl || "",
mapPcdUrl: car.mapPcdUrl || "",
whiteLightUrl: car.cameraWebrtcUrl || "",
infraredUrl: car.cameraWebrtcUrl2 || "",
dockStatus: car.dockStatus,
@@ -517,6 +532,24 @@ const mapDragState = ref({
originY: 0,
});
const rosMap = ref<RosMapState | null>(null);
// 三维点云地图:与二维地图共用同一套 map 坐标系
const mapViewMode = ref<MapViewMode>("2d");
const pointCloudQuality = ref<PointCloudQuality>("normal");
const pointCloudMaxPoints = computed(
() => pointCloudQualityOptions.find((option) => option.value === pointCloudQuality.value)?.maxPoints ?? 800000,
);
const map3dContainerRef = ref<HTMLDivElement | null>(null);
const {
loading: pointCloudLoading,
error: pointCloudError,
info: pointCloudInfo,
mount: mountPointCloud,
loadPointCloud,
setRoute: setPointCloudRoute,
setRobotPose: setPointCloudRobotPose,
setKeyboardNavigation: setPointCloudKeyboardNavigation,
resetView: resetPointCloudView,
} = usePointCloud3D(map3dContainerRef);
const mapLoading = ref(false);
const mapError = ref("");
const routeNodes = ref<InspectionRouteNode[]>([]);
@@ -667,6 +700,8 @@ const currentScenario = computed(() => navScenario);
const hasRobotPose = computed(
() => typeof currentRobot.value.poseX === "number" && typeof currentRobot.value.poseY === "number",
);
// 当前机器人是否已在后台配置点云文件,未配置时三维视图给出提示
const hasPointCloud = computed(() => Boolean(currentRobot.value.mapPcdUrl));
// 回充状态文案:由 charging_dock 节点的 /dock_status 上报,未上报时给出中性提示
const chargingStatusText = computed(() => {
const device = currentRobot.value;
@@ -1060,6 +1095,7 @@ function applyCarStatusPush(data: CarStatusPushData) {
poseYaw: typeof data.poseYaw === "number" ? data.poseYaw : currentDevice.poseYaw,
mapPgmUrl: data.mapPgmUrl || currentDevice.mapPgmUrl,
mapYamlUrl: data.mapYamlUrl || currentDevice.mapYamlUrl,
mapPcdUrl: data.mapPcdUrl || currentDevice.mapPcdUrl,
dockStatus: typeof data.dockStatus === "string" ? data.dockStatus : currentDevice.dockStatus,
dockActive: typeof data.dockActive === "boolean" ? data.dockActive : currentDevice.dockActive,
};
@@ -1456,6 +1492,55 @@ watch(
{ immediate: true },
);
// 切到三维视图(或点云地址、质量档位变化)时惰性初始化 WebGL 场景并按档位加载点云
watch(
[mapViewMode, () => currentRobot.value.mapPcdUrl, pointCloudMaxPoints],
async ([mode, mapPcdUrl, maxPoints]) => {
if (mode !== "3d") {
return;
}
await nextTick();
// 首次进入三维视图时动态加载 three.js 并初始化场景
await mountPointCloud();
await loadPointCloud(mapPcdUrl ? buildFileAccessUrl(mapPcdUrl) : "", maxPoints);
},
{ immediate: true },
);
// 三维地图放大预览时开启 WASD 键盘漫游,退出放大或切回二维后关闭
watch(
[mapPreviewVisible, mapViewMode],
([previewVisible, mode]) => {
setPointCloudKeyboardNavigation(previewVisible && mode === "3d");
},
{ immediate: true },
);
// 巡检路线同步到三维视图,在地面绘制路径与节点
watch(
routeNodes,
() => {
setPointCloudRoute(routeNodes.value.map((node) => ({ x: node.poseX, y: node.poseY })));
},
{ immediate: true },
);
// 机器人实时位姿同步到三维视图标记
watch(
[() => currentRobot.value.poseX, () => currentRobot.value.poseY, () => currentRobot.value.poseYaw],
() => {
setPointCloudRobotPose(hasRobotPose.value
? {
x: currentRobot.value.poseX as number,
y: currentRobot.value.poseY as number,
yaw: currentRobot.value.poseYaw ?? 0,
}
: null);
},
{ immediate: true },
);
function switchAlertTab(tab: (typeof alertTabs)[number]) {
activeAlertTab.value = tab;
fetchRecentAlerts();
@@ -1748,7 +1833,20 @@ function getStatusClass(status: DeviceItem["status"]) {
<div class="middle">
<!-- card- 前缀复用与其他面板一致的卡片标题样式 -->
<div class="card-map nav-map-panel">
<div class="title">管廊实时地图</div>
<div class="map-card-head">
<div class="title">管廊实时地图</div>
<!-- 二维栅格地图 / 三维点云地图切换 -->
<div class="map-view-switch">
<button :class="['map-view-btn', { active: mapViewMode === '2d' }]" type="button"
@click="mapViewMode = '2d'">
2D 地图
</button>
<button :class="['map-view-btn', { active: mapViewMode === '3d' }]" type="button"
@click="mapViewMode = '3d'">
3D 点云
</button>
</div>
</div>
<!-- 预览遮罩:点击空白处也可关闭 -->
<div v-if="mapPreviewVisible" class="map-preview-mask" @click="closeMapPreview"></div>
<div :class="['nav-map-stage', { 'preview-mode': mapPreviewVisible }]">
@@ -1760,10 +1858,14 @@ function getStatusClass(status: DeviceItem["status"]) {
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button v-else class="map-preview-close" type="button" @click.stop="closeMapPreview">
✕
<button v-else class="map-preview-close" type="button" title="退出预览" aria-label="退出预览"
@click.stop="closeMapPreview">
<svg class="map-preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
</svg>
</button>
<svg v-if="rosMap" ref="navMapSvgRef" :class="['nav-map-svg', 'real-map-svg', { dragging: mapDragState.dragging }]" :viewBox="rosMap.viewBox" preserveAspectRatio="xMidYMid meet"
<svg v-if="mapViewMode === '2d' && rosMap" ref="navMapSvgRef" :class="['nav-map-svg', 'real-map-svg', { dragging: mapDragState.dragging }]" :viewBox="rosMap.viewBox" preserveAspectRatio="xMidYMid meet"
@wheel="handleMapWheel" @pointerdown="startMapDrag" @pointermove="moveMapDrag" @pointerup="stopMapDrag"
@pointerleave="stopMapDrag" @pointercancel="stopMapDrag">
<defs>
@@ -1805,7 +1907,7 @@ function getStatusClass(status: DeviceItem["status"]) {
</g>
</svg>
<!-- 未配置地图:使用默认隧道示意图,机器人叠加在图中车辆位置 -->
<svg v-else class="nav-map-svg" viewBox="0 0 1398 467" preserveAspectRatio="xMidYMid meet">
<svg v-else-if="mapViewMode === '2d'" class="nav-map-svg" viewBox="0 0 1398 467" preserveAspectRatio="xMidYMid meet">
<image class="empty-map-image" :href="emptyMapImage" x="0" y="0" width="1398" height="467" />
<g class="robot-layer" transform="translate(1216, 266)">
<!-- 机器人光圈:由内向外扩散的三层圆环 -->
@@ -1816,18 +1918,46 @@ function getStatusClass(status: DeviceItem["status"]) {
<image class="robot-icon" :href="robotIcon" x="-14" y="-8" width="28" height="15" />
</g>
</svg>
<div v-if="mapLoading" class="map-state-tip">地图加载中...</div>
<div v-else-if="mapError" class="map-state-tip">{{ mapError }}</div>
<div v-else-if="routeLoading" class="map-state-tip">路线加载中...</div>
<div v-else-if="routeError" class="map-state-tip">{{ routeError }}</div>
<!-- 三维点云地图:WebGL 容器常驻,仅切换显示,避免来回切换时反复重建场景 -->
<div v-show="mapViewMode === '3d'" ref="map3dContainerRef" class="nav-map-3d"></div>
<div v-if="mapViewMode === '3d' && pointCloudInfo" class="nav-map-3d-info">
{{ pointCloudInfo }}
</div>
<!-- 放大预览下的三维操作提示 -->
<div v-if="mapViewMode === '3d' && mapPreviewVisible" class="nav-map-3d-hint">
WASD 移动视角 · 拖拽旋转 · 滚轮缩放
</div>
<template v-if="mapViewMode === '2d'">
<div v-if="mapLoading" class="map-state-tip">地图加载中...</div>
<div v-else-if="mapError" class="map-state-tip">{{ mapError }}</div>
<div v-else-if="routeLoading" class="map-state-tip">路线加载中...</div>
<div v-else-if="routeError" class="map-state-tip">{{ routeError }}</div>
</template>
<template v-else>
<div v-if="pointCloudLoading" class="map-state-tip">点云加载中...</div>
<div v-else-if="pointCloudError" class="map-state-tip">{{ pointCloudError }}</div>
<div v-else-if="!hasPointCloud" class="map-state-tip">
当前机器人未配置点云文件,请在设备管理 - 机器人配置中上传 PCD
</div>
</template>
<!-- <div class="nav-map-overlay top-left">ROS 实时地图</div>
<div class="nav-map-overlay top-right">真实坐标路径跟踪</div> -->
<div v-if="rosMap" class="map-scale-controls">
<div v-if="rosMap && mapViewMode === '2d'" class="map-scale-controls">
<button type="button" @click="changeMapScale(-0.05)">-</button>
<span>{{ Math.round(mapScale * 100) }}%</span>
<button type="button" @click="changeMapScale(0.05)">+</button>
<button type="button" @click="resetMapScale">重置</button>
</div>
<div v-else-if="mapViewMode === '3d'" class="map-scale-controls">
<!-- 点云质量档位:点数越多越还原,但帧率越低 -->
<button v-for="option in pointCloudQualityOptions" :key="option.value"
:class="['map-quality-btn', { active: pointCloudQuality === option.value }]"
:title="`渲染上限 ${option.maxPoints.toLocaleString('en-US')} 点`" type="button"
@click="pointCloudQuality = option.value">
{{ option.label }}
</button>
<button type="button" @click="resetPointCloudView">重置视角</button>
</div>
<div class="nav-map-overlay bottom-left">
机器人编号:{{ currentRobot.id }} / {{ currentRobot.task }}
</div>