feat(dashboard1): 首页地图支持三维点云展示,机器人配置支持上传 PCD
- 设备管理机器人配置弹窗新增点云 PCD 上传,地址保存到 ros_car.mapPcdUrl - 首页地图新增 2D/3D 切换,三维视图动态加载 three.js 渲染 PCD 点云 - 三维视图:按 z 直方图估算地面使小车与路径贴地、四轮小车模型、地面绘制巡检路径、放大预览支持 WASD 移动视角 - 地面按占用栅格生成连续平面,并剔除地面带内的点云,避免重复渲染 - PCD 解析支持 ascii/binary/binary_compressed,按流畅/标准/精细三档均匀抽稀 - 二维放大保留原拖拽平移与滚轮缩放
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user