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

65
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@types/node": "^20.4.4", "@types/node": "^20.4.4",
"@types/three": "^0.180.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"echarts": "^5.4.3", "echarts": "^5.4.3",
"echarts-liquidfill": "^3.1.0", "echarts-liquidfill": "^3.1.0",
@@ -16,6 +17,7 @@
"ol": "^7.4.0", "ol": "^7.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"three": "^0.180.0",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-router": "^5.1.0" "vue-router": "^5.1.0"
}, },
@@ -444,6 +446,12 @@
"vue": "^3.0.11" "vue": "^3.0.11"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
@@ -905,6 +913,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -956,6 +970,33 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.22.0"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@vicons/fluent": { "node_modules/@vicons/fluent": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmmirror.com/@vicons/fluent/-/fluent-0.12.0.tgz", "resolved": "https://registry.npmmirror.com/@vicons/fluent/-/fluent-0.12.0.tgz",
@@ -1257,6 +1298,12 @@
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@webgpu/types": {
"version": "0.1.74",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz",
@@ -1795,6 +1842,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
@@ -2572,6 +2625,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/meshoptimizer": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
"license": "MIT"
},
"node_modules/mime": { "node_modules/mime": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
@@ -3215,6 +3274,12 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@types/node": "^20.4.4", "@types/node": "^20.4.4",
"@types/three": "^0.180.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"echarts": "^5.4.3", "echarts": "^5.4.3",
"echarts-liquidfill": "^3.1.0", "echarts-liquidfill": "^3.1.0",
@@ -18,6 +19,7 @@
"ol": "^7.4.0", "ol": "^7.4.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"three": "^0.180.0",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-router": "^5.1.0" "vue-router": "^5.1.0"
}, },

View File

@@ -66,6 +66,8 @@ export interface RosCar {
mapPgmUrl?: string; mapPgmUrl?: string;
/** 地图 YAML 文件 URL 地址 */ /** 地图 YAML 文件 URL 地址 */
mapYamlUrl?: string; mapYamlUrl?: string;
/** 地图 PCD 点云文件 URL 地址(前端三维地图展示用) */
mapPcdUrl?: string;
/** 升降柱高度 */ /** 升降柱高度 */
liftHeight?: number; liftHeight?: number;
/** 升降柱状态 */ /** 升降柱状态 */
@@ -307,6 +309,7 @@ export function updateRosCarMapUrls(data: {
id: string; id: string;
mapPgmUrl?: string; mapPgmUrl?: string;
mapYamlUrl?: string; mapYamlUrl?: string;
mapPcdUrl?: string;
}) { }) {
return request<ApiResult<null>>({ return request<ApiResult<null>>({
url: "/admin/rosCar/updateMapUrls", url: "/admin/rosCar/updateMapUrls",

574
src/utils/pcd.ts Normal file
View File

@@ -0,0 +1,574 @@
/**
* PCD 点云文件解析工具(仅取 x/y/z 三个坐标字段,供前端三维地图渲染使用)。
*
* 支持 DATA ascii / binary / binary_compressed 三种存储方式,
* 后者按 PCL 约定需要先做 LZF 解压,且解压后是字段分块的 SOA 布局。
*/
/** 解析结果 */
export interface ParsedPointCloud {
/** 扁平存放的点坐标,顺序为 x, y, z ...(单位:米) */
positions: Float32Array;
/** 降采样后保留的点数量 */
pointCount: number;
/** 文件中的原始点数量 */
rawPointCount: number;
/** 点云包围盒,用于相机自适应取景 */
bounds: {
min: [number, number, number];
max: [number, number, number];
};
/** 估算出的地面高度(z,单位:米),用于把机器人模型与巡检路径贴合地面 */
groundZ: number;
}
interface PcdField {
name: string;
size: number;
type: string;
count: number;
}
interface PcdHeader {
fields: PcdField[];
points: number;
dataType: string;
/** 头部结束位置(数据区起始字节偏移) */
dataOffset: number;
}
/** 单个字段占用的字节数 */
function fieldByteSize(field: PcdField) {
return field.size * field.count;
}
/**
* 解析 PCD 头部;latin1 解码保证一个字符对应一个字节,便于把字符下标当作字节偏移使用。
*/
function parseHeader(bytes: Uint8Array): PcdHeader {
const headLength = Math.min(bytes.length, 65536);
const headText = new TextDecoder("latin1").decode(bytes.subarray(0, headLength));
const dataMatch = /(?:^|\r?\n)\s*DATA\s+([A-Za-z_]+)\s*(?:\r?\n)/.exec(headText);
if (!dataMatch) {
throw new Error("PCD 文件缺少 DATA 段声明");
}
const headerText = headText.slice(0, dataMatch.index);
const valuesOf = (key: string) => {
const match = headerText.match(new RegExp(`^\\s*${key}\\s+(.+)$`, "m"));
return match ? match[1].trim().split(/\s+/) : [];
};
const names = valuesOf("FIELDS");
const sizes = valuesOf("SIZE").map(Number);
const types = valuesOf("TYPE");
const counts = valuesOf("COUNT").map(Number);
const points = Number(valuesOf("POINTS")[0] || 0);
if (!names.length || names.length !== sizes.length || names.length !== types.length) {
throw new Error("PCD 头部字段描述不完整");
}
const fields: PcdField[] = names.map((name, index) => ({
name: name.toLowerCase(),
size: sizes[index] || 4,
type: (types[index] || "F").toUpperCase(),
count: counts[index] || 1,
}));
const xyzFields = fields.filter((field) => field.name === "x" || field.name === "y" || field.name === "z");
if (xyzFields.length < 3) {
throw new Error("PCD 文件缺少 x/y/z 坐标字段");
}
return {
fields,
points: points || Number(valuesOf("WIDTH")[0] || 0) * Number(valuesOf("HEIGHT")[0] || 1),
dataType: dataMatch[1].toLowerCase(),
dataOffset: dataMatch.index + dataMatch[0].length,
};
}
/** LZF 解压(PCL binary_compressed 使用的算法) */
function decompressLzf(input: Uint8Array, outputLength: number): Uint8Array {
const output = new Uint8Array(outputLength);
let inputIndex = 0;
let outputIndex = 0;
while (inputIndex < input.length) {
const control = input[inputIndex];
inputIndex += 1;
if (control < 32) {
const length = control + 1;
if (outputIndex + length > outputLength) {
throw new Error("PCD 点云数据解压越界");
}
output.set(input.subarray(inputIndex, inputIndex + length), outputIndex);
inputIndex += length;
outputIndex += length;
continue;
}
let length = control >> 5;
let reference = outputIndex - ((control & 0x1f) << 8) - 1;
if (length === 7) {
length += input[inputIndex];
inputIndex += 1;
}
reference -= input[inputIndex];
inputIndex += 1;
if (reference < 0 || outputIndex + length + 2 > outputLength) {
throw new Error("PCD 点云数据解压引用越界");
}
output[outputIndex] = output[reference];
output[outputIndex + 1] = output[reference + 1];
outputIndex += 2;
reference += 2;
for (let index = 0; index < length; index += 1) {
output[outputIndex] = output[reference];
outputIndex += 1;
reference += 1;
}
}
return output;
}
/** 按字段类型读取单个数值 */
function readFieldValue(view: DataView, offset: number, type: string, size: number) {
switch (type) {
case "F":
return size === 8 ? view.getFloat64(offset, true) : view.getFloat32(offset, true);
case "I":
if (size === 1) return view.getInt8(offset);
if (size === 2) return view.getInt16(offset, true);
if (size === 8) return Number(view.getBigInt64(offset, true));
return view.getInt32(offset, true);
case "U":
if (size === 1) return view.getUint8(offset);
if (size === 2) return view.getUint16(offset, true);
if (size === 8) return Number(view.getBigUint64(offset, true));
return view.getUint32(offset, true);
default:
return Number.NaN;
}
}
/**
* 计算 x/y/z 三个坐标字段的读取偏移。
*
* AOS(binary)逐点连续存放,坐标偏移在单点内固定,靠 pointStride 跳到下一个点;
* SOA(binary_compressed)按字段分块存放,每个坐标字段各自逐点步进 fieldStride。
*/
function resolveXyzLayout(fields: PcdField[], isSoa: boolean, points: number) {
const offsets: Record<string, number> = {};
const fieldStrides: Record<string, number> = {};
let cursor = 0;
fields.forEach((field) => {
const fieldBytes = fieldByteSize(field);
if (field.name === "x" || field.name === "y" || field.name === "z") {
offsets[field.name] = cursor;
fieldStrides[field.name] = isSoa ? field.size : 0;
}
cursor += isSoa ? fieldBytes * points : fieldBytes;
});
return { offsets, fieldStrides, pointStride: isSoa ? 0 : cursor };
}
/** 解析 ascii 文本格式的点坐标 */
function parseAsciiPoints(content: string, header: PcdHeader, target: PointTarget) {
const xyzIndex = header.fields
.map((field, index) => ({ name: field.name, index }))
.filter((item) => item.name === "x" || item.name === "y" || item.name === "z");
const indexOf = Object.fromEntries(xyzIndex.map((item) => [item.name, item.index]));
const lines = content.split(/\r?\n/);
let written = 0;
let valid = 0;
let nextKeep = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const parts = trimmed.split(/\s+/);
const x = Number(parts[indexOf.x]);
const y = Number(parts[indexOf.y]);
const z = Number(parts[indexOf.z]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
continue;
}
// 抽稀按有效点计数,保证取样在点云里均匀分布
if (valid >= nextKeep) {
target.positions[written * 3] = x;
target.positions[written * 3 + 1] = y;
target.positions[written * 3 + 2] = z;
written += 1;
nextKeep += target.interval;
}
valid += 1;
if (written >= target.capacity) {
break;
}
}
return { written, valid };
}
/**
* 目标点缓冲区:容量在解析前按抽稀间隔算好,解析时直接写进 Float32Array,
* 省掉"先建一份 JS number 数组再拷贝"的中间开销(大点云上是主要的内存与耗时来源)。
*/
interface PointTarget {
positions: Float32Array;
/**
* 抽稀间隔:平均每 interval 个有效点保留 1 个。
* 用浮点间隔而不是整数倍,保留点数才能精确贴近传入的上限
* (整数倍会让 30 万的上限实际只留下 19 万)。
*/
interval: number;
capacity: number;
}
/** 按原始点数与显示上限创建写入缓冲区 */
function createPointTarget(rawPointCount: number, maxPoints: number): PointTarget {
const safeRawCount = Math.max(Math.floor(rawPointCount) || 0, 1);
const interval = Math.max(safeRawCount / maxPoints, 1);
const capacity = Math.ceil(safeRawCount / interval) + 2;
return {
positions: new Float32Array(capacity * 3),
interval,
capacity,
};
}
/** 解析二进制(含压缩)格式的点坐标 */
function parseBinaryPoints(
view: DataView,
header: PcdHeader,
dataStart: number,
dataLength: number,
target: PointTarget,
) {
const isSoa = header.dataType === "binary_compressed";
const { offsets, fieldStrides, pointStride } = resolveXyzLayout(header.fields, isSoa, header.points);
let written = 0;
let valid = 0;
let nextKeep = 0;
for (let index = 0; index < header.points; index += 1) {
const base = dataStart + index * pointStride;
const xOffset = base + offsets.x + index * fieldStrides.x;
const yOffset = base + offsets.y + index * fieldStrides.y;
const zOffset = base + offsets.z + index * fieldStrides.z;
// 防止截断文件越界读取
if (Math.max(xOffset, yOffset, zOffset) + 4 > dataStart + dataLength) {
break;
}
const x = readFieldValue(view, xOffset, "F", 4);
const y = readFieldValue(view, yOffset, "F", 4);
const z = readFieldValue(view, zOffset, "F", 4);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
continue;
}
// 抽稀按有效点计数,保证取样在点云里均匀分布
if (valid >= nextKeep) {
target.positions[written * 3] = x;
target.positions[written * 3 + 1] = y;
target.positions[written * 3 + 2] = z;
written += 1;
nextKeep += target.interval;
}
valid += 1;
if (written >= target.capacity) {
break;
}
}
return { written, valid };
}
/** 根据 x/y/z 扁平数组计算包围盒 */
function resolveBounds(coordinates: Float32Array, pointCount: number) {
const min: [number, number, number] = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
const max: [number, number, number] = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
for (let index = 0; index < pointCount; index += 1) {
for (let axis = 0; axis < 3; axis += 1) {
const value = coordinates[index * 3 + axis];
if (value < min[axis]) min[axis] = value;
if (value > max[axis]) max[axis] = value;
}
}
if (!Number.isFinite(min[0])) {
return { min: [0, 0, 0] as [number, number, number], max: [0, 0, 0] as [number, number, number] };
}
return { min, max };
}
/**
* 估算地面高度:从最低处向上找第一个点数足够密集的 z 分层。
*
* 长廊/隧道点云里地面是面积最大的水平面,用直方图取“第一个密集分层”比直接取
* 最低点更稳:既不会被地面以下的离群噪点带偏,也不受墙面点影响。
*/
function estimateGroundZ(positions: Float32Array, pointCount: number, bounds: ParsedPointCloud["bounds"]) {
const span = bounds.max[2] - bounds.min[2];
if (pointCount < 100 || span < 0.2) {
return bounds.min[2];
}
const binCount = Math.min(Math.max(Math.ceil(span / 0.05), 1), 2048);
const scale = binCount / span;
const bins = new Int32Array(binCount);
for (let index = 0; index < pointCount; index += 1) {
const bin = Math.min(Math.floor((positions[index * 3 + 2] - bounds.min[2]) * scale), binCount - 1);
bins[bin] += 1;
}
let peak = 0;
for (let index = 0; index < binCount; index += 1) {
if (bins[index] > peak) {
peak = bins[index];
}
}
// 峰值 25% 以上视为密集层,最低的那个密集层就是地面
const threshold = peak * 0.25;
let floorBin = 0;
for (let index = 0; index < binCount; index += 1) {
if (bins[index] >= threshold) {
floorBin = index;
break;
}
}
const lower = bounds.min[2] + floorBin / scale - 0.1;
const upper = bounds.min[2] + (floorBin + 1) / scale + 0.1;
let sum = 0;
let count = 0;
for (let index = 0; index < pointCount; index += 1) {
const z = positions[index * 3 + 2];
if (z >= lower && z <= upper) {
sum += z;
count += 1;
}
}
return count ? sum / count : bounds.min[2] + (floorBin + 0.5) / scale;
}
/** 地面连续平面的三角网格数据 */
export interface GroundSurface {
/** 三角形顶点坐标,按 x, y, z 排列 */
positions: Float32Array;
/** 顶点法线,全部朝上 */
normals: Float32Array;
/** 栅格边长(米) */
cellSize: number;
/** 生成平面时取用的地面带半高(米):|z - groundZ| ≤ band 的点已由平面表达 */
band: number;
/** 生成的方格数量 */
quadCount: number;
}
/**
* 用点云里的地面点栅格化出一块连续平面,用来填补点与点之间的空隙。
*
* 做法:把地面带(z 在 groundZ 附近)内的点投到二维占用栅格上,邻域膨胀一圈补掉
* 点云稀疏造成的空洞,再按占用格子生成水平三角面。长廊地面因此是连续平面,
* 墙面仍保留点云细节。
*/
export function buildGroundSurface(parsed: ParsedPointCloud): GroundSurface | null {
const { min, max } = parsed.bounds;
const spanX = Math.max(max[0] - min[0], 0.5);
const spanY = Math.max(max[1] - min[1], 0.5);
// 栅格边长随场景尺度自适应,并把总格子数限制在 20 万以内
let cellSize = Math.min(Math.max(spanX / 400, 0.06), 0.4);
while ((spanX / cellSize) * (spanY / cellSize) > 200000) {
cellSize *= 1.5;
}
const cols = Math.ceil(spanX / cellSize) + 1;
const rows = Math.ceil(spanY / cellSize) + 1;
const occupied = new Uint8Array(cols * rows);
const band = Math.max(cellSize * 0.5, 0.1);
for (let index = 0; index < parsed.pointCount; index += 1) {
if (Math.abs(parsed.positions[index * 3 + 2] - parsed.groundZ) > band) {
continue;
}
const col = Math.floor((parsed.positions[index * 3] - min[0]) / cellSize);
const row = Math.floor((parsed.positions[index * 3 + 1] - min[1]) / cellSize);
if (col < 0 || row < 0 || col >= cols || row >= rows) {
continue;
}
occupied[row * cols + col] = 1;
}
// 邻域膨胀一圈,把局部点云稀疏造成的空洞补齐
const filled = new Uint8Array(occupied);
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
if (!occupied[row * cols + col]) {
continue;
}
for (let rowOffset = -1; rowOffset <= 1; rowOffset += 1) {
for (let colOffset = -1; colOffset <= 1; colOffset += 1) {
const targetRow = row + rowOffset;
const targetCol = col + colOffset;
if (targetRow >= 0 && targetRow < rows && targetCol >= 0 && targetCol < cols) {
filled[targetRow * cols + targetCol] = 1;
}
}
}
}
}
let quadCount = 0;
for (let index = 0; index < filled.length; index += 1) {
if (filled[index]) {
quadCount += 1;
}
}
if (!quadCount) {
return null;
}
const positions = new Float32Array(quadCount * 18);
const normals = new Float32Array(quadCount * 18);
// 略低于点云地面,让地面点浮在平面之上,避免与平面深度打架
const planeZ = parsed.groundZ - 0.02;
let cursor = 0;
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
if (!filled[row * cols + col]) {
continue;
}
const x0 = min[0] + col * cellSize;
const y0 = min[1] + row * cellSize;
const x1 = x0 + cellSize;
const y1 = y0 + cellSize;
// 逆时针两个三角形(从 +Z 方向看为正面)
const corners = [x0, y0, x1, y0, x1, y1, x0, y0, x1, y1, x0, y1];
for (let corner = 0; corner < 6; corner += 1) {
positions[cursor] = corners[corner * 2];
positions[cursor + 1] = corners[corner * 2 + 1];
positions[cursor + 2] = planeZ;
normals[cursor] = 0;
normals[cursor + 1] = 0;
normals[cursor + 2] = 1;
cursor += 3;
}
}
}
return { positions, normals, cellSize, band, quadCount };
}
export interface ParsePcdOptions {
/** 保留的点数上限,超出后按该数量在点云里均匀抽稀,默认 80 万 */
maxPoints?: number;
}
/**
* 解析 PCD 点云文件,返回三维渲染所需的扁平坐标数组。
*
* @param buffer PCD 文件内容
* @param options 抽稀参数
*/
export function parsePcdPointCloud(buffer: ArrayBuffer, options: ParsePcdOptions = {}): ParsedPointCloud {
const bytes = new Uint8Array(buffer);
const header = parseHeader(bytes);
if (!header.points) {
throw new Error("PCD 文件点数量为 0");
}
const maxPoints = options.maxPoints ?? 800000;
const target = createPointTarget(header.points, maxPoints);
let parsed: { written: number; valid: number };
if (header.dataType === "ascii") {
const content = new TextDecoder("latin1").decode(bytes.subarray(header.dataOffset));
parsed = parseAsciiPoints(content, header, target);
} else if (header.dataType === "binary") {
const view = new DataView(buffer);
parsed = parseBinaryPoints(view, header, header.dataOffset, buffer.byteLength - header.dataOffset, target);
} else if (header.dataType === "binary_compressed") {
if (header.dataOffset + 8 > buffer.byteLength) {
throw new Error("PCD 压缩数据区不完整");
}
const headView = new DataView(buffer, header.dataOffset, 8);
const compressedSize = headView.getUint32(0, true);
const uncompressedSize = headView.getUint32(4, true);
const compressed = bytes.subarray(header.dataOffset + 8, header.dataOffset + 8 + compressedSize);
if (compressed.length < compressedSize) {
throw new Error("PCD 压缩数据长度与声明不符");
}
const plain = decompressLzf(compressed, uncompressedSize);
const view = new DataView(plain.buffer, plain.byteOffset, plain.byteLength);
parsed = parseBinaryPoints(view, header, 0, plain.byteLength, target);
} else {
throw new Error(`暂不支持的 PCD 数据格式:${header.dataType}`);
}
if (!parsed.written) {
throw new Error("PCD 文件中未解析到有效点");
}
const finalPointCount = parsed.written;
const finalPositions = target.positions.subarray(0, finalPointCount * 3);
const bounds = resolveBounds(finalPositions, finalPointCount);
return {
positions: finalPositions,
pointCount: finalPointCount,
rawPointCount: parsed.valid,
bounds,
groundZ: estimateGroundZ(finalPositions, finalPointCount, bounds),
};
}

View File

@@ -0,0 +1,861 @@
import { onBeforeUnmount, ref, type Ref } from "vue";
import type {
Color,
GridHelper,
Group,
Material,
Mesh,
PerspectiveCamera,
Points,
Scene,
Vector3,
WebGLRenderer,
} from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import {
buildGroundSurface,
parsePcdPointCloud,
type GroundSurface,
type ParsedPointCloud,
} from "@/utils/pcd";
/** 机器人在三维地图中的位姿(map 坐标系,单位:米/弧度) */
export interface RobotPose3D {
x: number;
y: number;
yaw: number;
}
/** 巡检路径节点(map 坐标系,单位:米) */
export interface RoutePoint3D {
x: number;
y: number;
}
/** three.js 模块类型,运行时再按需加载,避免三维依赖拖慢首页首屏 */
type ThreeModule = typeof import("three");
/** 三维模型按真实小车尺寸放大倍数,保证在长廊场景下能看清 */
const MODEL_SCALE = 2;
/**
* 点尺寸系数:点世界尺寸 = 场景尺度 / 该值,数值越大点越细。
* 方点比同尺寸圆点覆盖面积大 27%,这里已按等效覆盖率折算过。
*/
const POINT_SIZE_DIVISOR = 900;
/** 点云原始文件缓存上限,超过则不缓存,换质量档位时改为重新下载 */
const RAW_CACHE_LIMIT = 64 * 1024 * 1024;
/** 默认抽稀上限(点数),页面会按质量档位传入覆盖 */
export const DEFAULT_POINT_CAP = 800000;
/** 高度着色渐变色标(低 → 高) */
const heightColorStops: Array<[number, number, number]> = [
[0.13, 0.42, 0.78],
[0.2, 0.72, 0.86],
[0.35, 0.85, 0.55],
[0.95, 0.83, 0.35],
[0.92, 0.42, 0.36],
];
/** 按归一化高度取渐变色 */
function resolveHeightColor(three: ThreeModule, ratio: number, target: Color) {
const safeRatio = Math.min(Math.max(ratio, 0), 1);
const scaled = safeRatio * (heightColorStops.length - 1);
const index = Math.min(Math.floor(scaled), heightColorStops.length - 2);
const localRatio = scaled - index;
const start = heightColorStops[index];
const end = heightColorStops[index + 1];
return target.setRGB(
start[0] + (end[0] - start[0]) * localRatio,
start[1] + (end[1] - start[1]) * localRatio,
start[2] + (end[2] - start[2]) * localRatio,
);
}
/**
* 三维点云地图渲染:读取 PCD 点云绘制点云主体,在地面绘制巡检路径,
* 并以四轮小车模型叠加机器人实时位姿。
*
* 坐标直接使用 ROS 的 map 坐标系(x 前 / y 左 / z 上),
* 因此把相机 up 设为 +Z 后即可与二维地图保持同一套坐标语义。
*/
export default function usePointCloud3D(containerRef: Ref<HTMLElement | null>) {
const loading = ref(false);
const error = ref("");
const info = ref("");
const loaded = ref(false);
let three: ThreeModule | null = null;
let renderer: WebGLRenderer | null = null;
let scene: Scene | null = null;
let camera: PerspectiveCamera | null = null;
let controls: OrbitControls | null = null;
let gridHelper: GridHelper | null = null;
let resizeObserver: ResizeObserver | null = null;
let frameId = 0;
let needsRender = false;
let mounting = false;
let mountToken = 0;
let pointCloud: Points | null = null;
/** 地面连续平面,填补点云空隙 */
let floorMesh: Mesh | null = null;
let robotGroup: Group | null = null;
let routeGroup: Group | null = null;
let cloudBounds: ParsedPointCloud["bounds"] | null = null;
let routeBounds: ParsedPointCloud["bounds"] | null = null;
let routePoints: RoutePoint3D[] = [];
/** 点云地面高度(长廊地面),由解析阶段按 z 直方图估算 */
let groundZ = 0;
let robotPose: RobotPose3D | null = null;
let loadedUrl = "";
/** 当前已加载点云用的抽稀上限,用于判断换质量档位后是否需要重新解析 */
let loadedCap = 0;
/** 点云原始文件缓存:换质量档位重新解析时不必再次下载,超出上限则不缓存 */
let rawCache: ArrayBuffer | null = null;
let rawCacheUrl = "";
/** 用户是否手动操作过相机,操作后不再自动调整视角 */
let cameraTouched = false;
/** WASD 键盘漫游状态(仅在地图放大预览时开启),速度随场景尺度自适应 */
const pressedKeys = new Set<string>();
let keyboardEnabled = false;
let keyboardSpeed = 5;
let lastFrameTime = 0;
const ownedGeometries: Array<{ dispose: () => void }> = [];
const ownedMaterials: Material[] = [];
function trackResources(geometries: Array<{ dispose: () => void }>, materials: Material[]) {
ownedGeometries.push(...geometries);
ownedMaterials.push(...materials);
}
function requestRender() {
needsRender = true;
}
function renderFrame() {
frameId = requestAnimationFrame(renderFrame);
const now = performance.now();
const deltaMs = lastFrameTime ? now - lastFrameTime : 16;
lastFrameTime = now;
// 键盘漫游需要逐帧位移,因此先于渲染判断执行
applyKeyboardNavigation(deltaMs);
if (!needsRender || !renderer || !scene || !camera) {
return;
}
needsRender = false;
controls?.update();
renderer.render(scene, camera);
}
function isNavigationKey(key: string) {
return key === "w" || key === "a" || key === "s" || key === "d";
}
function handleNavigationKeyDown(event: KeyboardEvent) {
if (!keyboardEnabled || event.metaKey || event.ctrlKey || event.altKey) {
return;
}
const key = event.key.toLowerCase();
if (!isNavigationKey(key)) {
return;
}
// 输入框内不劫持按键,避免影响页面其它表单输入
const target = event.target as HTMLElement | null;
const tagName = target?.tagName;
if (tagName === "INPUT" || tagName === "TEXTAREA" || target?.isContentEditable) {
return;
}
event.preventDefault();
pressedKeys.add(key);
requestRender();
}
function handleNavigationKeyUp(event: KeyboardEvent) {
pressedKeys.delete(event.key.toLowerCase());
}
function clearPressedKeys() {
pressedKeys.clear();
}
/** 开启/关闭 WASD 键盘漫游,仅在三维地图放大预览时开启 */
function setKeyboardNavigation(enabled: boolean) {
if (enabled === keyboardEnabled) {
return;
}
keyboardEnabled = enabled;
pressedKeys.clear();
if (enabled) {
window.addEventListener("keydown", handleNavigationKeyDown);
window.addEventListener("keyup", handleNavigationKeyUp);
window.addEventListener("blur", clearPressedKeys);
} else {
window.removeEventListener("keydown", handleNavigationKeyDown);
window.removeEventListener("keyup", handleNavigationKeyUp);
window.removeEventListener("blur", clearPressedKeys);
}
}
/**
* 按帧位移相机:W/S 沿相机水平朝向前后移动,A/D 沿水平右方向平移,
* 位移同时作用在相机与轨道中心上,移动过程中保持当前观察姿态。
*/
function applyKeyboardNavigation(deltaMs: number) {
if (!pressedKeys.size || !three || !camera || !controls) {
return;
}
const forward = new three.Vector3();
camera.getWorldDirection(forward);
// 只在水平面内移动,避免视角俯仰把相机带进地面或天花板
forward.z = 0;
if (forward.lengthSq() < 1e-6) {
return;
}
const right = new three.Vector3().crossVectors(forward.normalize(), camera.up).normalize();
const step = keyboardSpeed * (Math.min(deltaMs, 100) / 1000);
const offset = new three.Vector3();
if (pressedKeys.has("w")) offset.addScaledVector(forward, step);
if (pressedKeys.has("s")) offset.addScaledVector(forward, -step);
if (pressedKeys.has("d")) offset.addScaledVector(right, step);
if (pressedKeys.has("a")) offset.addScaledVector(right, -step);
if (offset.lengthSq() === 0) {
return;
}
cameraTouched = true;
camera.position.add(offset);
controls.target.add(offset);
requestRender();
}
function resizeRenderer() {
const container = containerRef.value;
if (!container || !renderer || !camera) {
return;
}
const width = Math.max(container.clientWidth, 1);
const height = Math.max(container.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
requestRender();
}
/** 销毁点云主体与地面网格,释放显存 */
function clearPointCloud() {
if (pointCloud && scene) {
scene.remove(pointCloud);
pointCloud.geometry.dispose();
(pointCloud.material as Material).dispose();
pointCloud = null;
}
if (floorMesh && scene) {
scene.remove(floorMesh);
floorMesh.geometry.dispose();
(floorMesh.material as Material).dispose();
floorMesh = null;
}
}
/**
* 把地面栅格数据转成 three.js 网格。
*
* 长廊地面是最完整的一片水平面,与其靠放大点尺寸去"糊"住空隙(近处会变成大色块),
* 不如按占用栅格铺一张地面网格;对应的地面点云会被剔除,不再重复渲染。
*/
function buildFloorMesh(threeModule: ThreeModule, surface: GroundSurface) {
const geometry = new threeModule.BufferGeometry();
geometry.setAttribute("position", new threeModule.BufferAttribute(surface.positions, 3));
geometry.setAttribute("normal", new threeModule.BufferAttribute(surface.normals, 3));
return new threeModule.Mesh(
geometry,
new threeModule.MeshLambertMaterial({ color: 0x1c3b52 }),
);
}
/** 清理巡检路径对象 */
function clearRoute() {
if (!routeGroup || !scene) {
return;
}
scene.remove(routeGroup);
routeGroup.traverse((child) => {
const mesh = child as Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material as Material | undefined;
material?.dispose?.();
});
routeGroup = null;
routeBounds = null;
}
/** 创建四轮小车模型(车身 + 4 个车轮 + 顶部雷达 + 地面光圈) */
function createRobotMarker() {
if (!three || !scene) {
return;
}
const group = new three.Group();
const bodyLength = 0.62 * MODEL_SCALE;
const bodyWidth = 0.42 * MODEL_SCALE;
const bodyHeight = 0.18 * MODEL_SCALE;
const wheelRadius = 0.1 * MODEL_SCALE;
const wheelWidth = 0.06 * MODEL_SCALE;
const chassisGeometry = new three.BoxGeometry(bodyLength, bodyWidth, bodyHeight);
const chassisMaterial = new three.MeshLambertMaterial({ color: 0xffb26e });
const chassis = new three.Mesh(chassisGeometry, chassisMaterial);
chassis.position.z = wheelRadius + bodyHeight / 2;
group.add(chassis);
// 顶部设备舱:小幅收窄并前移,直观区分车头
const cabinGeometry = new three.BoxGeometry(bodyLength * 0.42, bodyWidth * 0.72, bodyHeight * 0.75);
const cabinMaterial = new three.MeshLambertMaterial({ color: 0x2f5d7c });
const cabin = new three.Mesh(cabinGeometry, cabinMaterial);
cabin.position.set(bodyLength * 0.16, 0, wheelRadius + bodyHeight * 1.35);
group.add(cabin);
// 顶部雷达:圆柱轴向默认沿 Y,绕 X 轴转 90° 后立起来
const radarGeometry = new three.CylinderGeometry(0.09 * MODEL_SCALE, 0.09 * MODEL_SCALE, 0.08 * MODEL_SCALE, 20);
const radarMaterial = new three.MeshLambertMaterial({ color: 0x66d7ff });
const radar = new three.Mesh(radarGeometry, radarMaterial);
radar.rotation.x = Math.PI / 2;
radar.position.set(bodyLength * 0.16, 0, wheelRadius + bodyHeight * 1.35 + bodyHeight * 0.42);
group.add(radar);
// 四个车轮:圆柱轴向默认沿 Y,正好是车轮的滚动轴
const wheelGeometry = new three.CylinderGeometry(wheelRadius, wheelRadius, wheelWidth, 18);
const wheelMaterial = new three.MeshLambertMaterial({ color: 0x1b2733 });
const wheelX = bodyLength * 0.32;
const wheelY = bodyWidth / 2 + wheelWidth / 2 - 0.01 * MODEL_SCALE;
const wheelPositions: Array<[number, number]> = [
[wheelX, wheelY],
[wheelX, -wheelY],
[-wheelX, wheelY],
[-wheelX, -wheelY],
];
for (const [x, y] of wheelPositions) {
const wheel = new three.Mesh(wheelGeometry, wheelMaterial);
wheel.position.set(x, y, wheelRadius);
group.add(wheel);
}
// 车头朝向箭头,方便从远处判断朝向
const arrow = new three.ArrowHelper(
new three.Vector3(1, 0, 0),
new three.Vector3(bodyLength * 0.5, 0, wheelRadius + bodyHeight * 0.5),
0.85 * MODEL_SCALE,
0xffd479,
0.22 * MODEL_SCALE,
0.14 * MODEL_SCALE,
);
group.add(arrow);
// 地面光圈:长廊地图下车体很小,用光圈保证远距离也能定位小车
const glowGeometry = new three.CircleGeometry(0.75 * MODEL_SCALE, 32);
const glowMaterial = new three.MeshBasicMaterial({
color: 0x66d7ff,
transparent: true,
opacity: 0.22,
depthWrite: false,
});
const glow = new three.Mesh(glowGeometry, glowMaterial);
glow.position.z = 0.04;
glow.renderOrder = 1;
group.add(glow);
trackResources(
[chassisGeometry, cabinGeometry, radarGeometry, wheelGeometry, glowGeometry],
[chassisMaterial, cabinMaterial, radarMaterial, wheelMaterial, glowMaterial],
);
robotGroup = group;
scene.add(group);
updateRobotMarker();
}
function updateRobotMarker() {
if (!robotGroup) {
return;
}
if (!robotPose) {
robotGroup.visible = false;
requestRender();
return;
}
robotGroup.visible = true;
robotGroup.position.set(robotPose.x, robotPose.y, groundZ);
// 模型内部正前方为 +X,随 yaw 绕 Z 轴旋转即得到实际朝向
robotGroup.rotation.z = robotPose.yaw;
requestRender();
}
/** 相机取景范围:点云与巡检路径的并集,保证路径一定在视野内 */
function resolveFrameBounds() {
if (!cloudBounds) {
return null;
}
if (!routeBounds) {
return cloudBounds;
}
return {
min: [
Math.min(cloudBounds.min[0], routeBounds.min[0]),
Math.min(cloudBounds.min[1], routeBounds.min[1]),
Math.min(cloudBounds.min[2], routeBounds.min[2]),
] as [number, number, number],
max: [
Math.max(cloudBounds.max[0], routeBounds.max[0]),
Math.max(cloudBounds.max[1], routeBounds.max[1]),
Math.max(cloudBounds.max[2], routeBounds.max[2]),
] as [number, number, number],
};
}
/**
* 默认视角:巡检场景是单向长廊,沿走廊方向侧视最贴合宽扁的地图面板。
* 相机在走廊侧向按视口宽高比算出刚好装下整条走廊的距离,再抬高一个仰角。
*/
function frameCamera() {
if (!three || !camera || !controls) {
return;
}
const bounds = resolveFrameBounds();
if (!bounds) {
return;
}
const { min, max } = bounds;
const sizeX = Math.max(max[0] - min[0], 0.5);
const sizeY = Math.max(max[1] - min[1], 0.5);
const sizeZ = Math.max(max[2] - min[2], 0.5);
// 巡检路径优先作为取景中心,没配置路径时回到点云中心
const target = routeBounds
? new three.Vector3(
(routeBounds.min[0] + routeBounds.max[0]) / 2,
(routeBounds.min[1] + routeBounds.max[1]) / 2,
(routeBounds.min[2] + routeBounds.max[2]) / 2,
)
: new three.Vector3(
(min[0] + max[0]) / 2,
(min[1] + max[1]) / 2,
(min[2] + max[2]) / 2,
);
const alongX = sizeX >= sizeY;
const length = alongX ? sizeX : sizeY;
const aspect = camera.aspect || 16 / 9;
const halfVFov = (camera.fov * Math.PI) / 360;
const halfHFov = Math.atan(Math.tan(halfVFov) * aspect);
const distance = Math.max(
(length / 2) / Math.tan(halfHFov),
(sizeZ / 2) / Math.tan(halfVFov),
Math.min(sizeX, sizeY) * 1.2,
3,
) * 1.05;
const elevation = 0.3;
const horizontal = distance * Math.cos(elevation);
const vertical = distance * Math.sin(elevation);
const offset = alongX
? new three.Vector3(-length * 0.06, -horizontal, vertical)
: new three.Vector3(-horizontal, -length * 0.06, vertical);
controls.target.copy(target);
camera.position.copy(target).add(offset);
camera.near = Math.max(distance / 5000, 0.05);
camera.far = distance * 20;
camera.updateProjectionMatrix();
controls.update();
requestRender();
}
/** 惰性初始化 three.js 场景(首次切到三维视图时调用) */
async function mount() {
const container = containerRef.value;
if (!container || renderer || mounting) {
return;
}
mounting = true;
const token = ++mountToken;
try {
const [threeModule, orbitModule] = await Promise.all([
import("three"),
import("three/examples/jsm/controls/OrbitControls.js"),
]);
// 加载期间视图被销毁或容器已被移除时放弃初始化
if (token !== mountToken || !containerRef.value) {
return;
}
three = threeModule;
renderer = new threeModule.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x000000, 0);
container.appendChild(renderer.domElement);
scene = new threeModule.Scene();
camera = new threeModule.PerspectiveCamera(55, 1, 0.1, 5000);
// Z 轴朝上,直接使用 ROS 坐标系
camera.up.set(0, 0, 1);
controls = new orbitModule.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.addEventListener("change", requestRender);
controls.addEventListener("start", () => {
cameraTouched = true;
});
scene.add(new threeModule.AmbientLight(0xffffff, 1.6));
const light = new threeModule.DirectionalLight(0xffffff, 1.1);
light.position.set(1, -1.6, 2.4);
scene.add(light);
gridHelper = new threeModule.GridHelper(60, 60, 0x2f4a63, 0x1d3145);
// GridHelper 默认在 XZ 平面,绕 X 轴旋转 90° 后铺在 XY 平面
gridHelper.rotation.x = Math.PI / 2;
scene.add(gridHelper);
createRobotMarker();
if (routePoints.length) {
buildRoute();
}
resizeObserver = new ResizeObserver(() => resizeRenderer());
resizeObserver.observe(container);
resizeRenderer();
renderFrame();
} catch (mountError) {
console.error(mountError);
error.value = "三维视图初始化失败,请确认浏览器支持 WebGL";
dispose();
} finally {
mounting = false;
}
}
/** 销毁 three.js 场景与事件监听 */
function dispose() {
mountToken += 1;
setKeyboardNavigation(false);
cancelAnimationFrame(frameId);
frameId = 0;
needsRender = false;
lastFrameTime = 0;
resizeObserver?.disconnect();
resizeObserver = null;
controls?.removeEventListener("change", requestRender);
controls?.dispose();
controls = null;
clearPointCloud();
clearRoute();
gridHelper = null;
ownedGeometries.splice(0).forEach((geometry) => geometry.dispose());
ownedMaterials.splice(0).forEach((material) => material.dispose());
if (robotGroup && scene) {
scene.remove(robotGroup);
}
robotGroup = null;
if (renderer) {
renderer.dispose();
renderer.domElement.remove();
renderer = null;
}
scene = null;
camera = null;
three = null;
}
/**
* 拉取并渲染 PCD 点云文件。
*
* @param url 点云文件地址
* @param maxPoints 抽稀上限(点数),决定渲染压力
*/
async function loadPointCloud(url: string, maxPoints = DEFAULT_POINT_CAP) {
// 三维视图未初始化时不做任何请求,交由切换到三维视图时再加载
if (!three || !renderer || !scene) {
return;
}
if (!url) {
clearPointCloud();
clearRoute();
cloudBounds = null;
groundZ = 0;
loaded.value = false;
loadedUrl = "";
loadedCap = 0;
rawCache = null;
rawCacheUrl = "";
info.value = "";
// 未配置点云由页面提示,不额外报错
error.value = "";
updateRobotMarker();
requestRender();
return;
}
// 同一文件且档位未变才跳过,换档位需要重新解析
if (loadedUrl === url && pointCloud && loadedCap === maxPoints) {
return;
}
loading.value = true;
error.value = "";
try {
let buffer: ArrayBuffer;
if (rawCacheUrl === url && rawCache) {
buffer = rawCache;
} else {
const response = await fetch(url);
if (!response.ok) {
throw new Error("点云文件访问失败");
}
buffer = await response.arrayBuffer();
// 缓存原始文件,换档位重新解析时不必再下载一次
rawCache = buffer.byteLength <= RAW_CACHE_LIMIT ? buffer : null;
rawCacheUrl = url;
}
const parsed = parsePcdPointCloud(buffer, { maxPoints });
const threeModule = three;
if (!threeModule || !renderer || !scene) {
return;
}
clearPointCloud();
// 先算地面平面:地面带内的点改由平面表达,下面构建点云时直接剔除,避免重复渲染
const surface = buildGroundSurface(parsed);
const groundBand = surface ? surface.band : 0;
const height = Math.max(parsed.bounds.max[2] - parsed.groundZ, 0.001);
const positions = new Float32Array(parsed.pointCount * 3);
const colors = new Float32Array(parsed.pointCount * 3);
const color = new threeModule.Color();
let keptCount = 0;
for (let index = 0; index < parsed.pointCount; index += 1) {
const x = parsed.positions[index * 3];
const y = parsed.positions[index * 3 + 1];
const z = parsed.positions[index * 3 + 2];
if (surface && Math.abs(z - parsed.groundZ) <= groundBand) {
continue;
}
const ratio = (z - parsed.groundZ) / height;
resolveHeightColor(threeModule, ratio, color);
positions[keptCount * 3] = x;
positions[keptCount * 3 + 1] = y;
positions[keptCount * 3 + 2] = z;
colors[keptCount * 3] = color.r;
colors[keptCount * 3 + 1] = color.g;
colors[keptCount * 3 + 2] = color.b;
keptCount += 1;
}
const geometry = new threeModule.BufferGeometry();
geometry.setAttribute(
"position",
new threeModule.BufferAttribute(positions.subarray(0, keptCount * 3), 3),
);
geometry.setAttribute(
"color",
new threeModule.BufferAttribute(colors.subarray(0, keptCount * 3), 3),
);
const extent = Math.max(
parsed.bounds.max[0] - parsed.bounds.min[0],
parsed.bounds.max[1] - parsed.bounds.min[1],
height,
);
// 方块点:不挂点精灵贴图,按默认方点渲染
const material = new threeModule.PointsMaterial({
size: Math.min(Math.max(extent / POINT_SIZE_DIVISOR, 0.02), 0.18),
sizeAttenuation: true,
vertexColors: true,
});
pointCloud = new threeModule.Points(geometry, material);
scene.add(pointCloud);
// 地面单独铺一块连续平面,对应的地面点已在上面剔除
if (surface) {
floorMesh = buildFloorMesh(threeModule, surface);
scene.add(floorMesh);
}
cloudBounds = parsed.bounds;
groundZ = parsed.groundZ;
loadedUrl = url;
loadedCap = maxPoints;
loaded.value = true;
const keptText = `${keptCount.toLocaleString("en-US")} 点 + 地面平面`;
info.value = parsed.pointCount === parsed.rawPointCount
? keptText
: `${keptText}(原始 ${parsed.rawPointCount.toLocaleString("en-US")} 点,已抽稀)`;
// 参考网格铺到地面平面之下,只在地面平面之外的区域可见,避免与平面打架
if (gridHelper) {
gridHelper.position.z = groundZ - 0.08;
const gridScale = Math.max(extent / 60, 1) * 1.2;
gridHelper.scale.set(gridScale, gridScale, 1);
}
// 键盘漫游速度随场景尺度自适应,长廊下车速更快
keyboardSpeed = Math.min(Math.max(extent / 12, 1.5), 15);
updateRobotMarker();
buildRoute();
if (!cameraTouched) {
frameCamera();
}
} catch (loadError) {
console.error(loadError);
loaded.value = false;
loadedUrl = "";
error.value = loadError instanceof Error ? loadError.message : "点云加载失败";
} finally {
loading.value = false;
}
}
/** 在地面绘制巡检路径(只画路线本身,不标注途经点/业务点) */
function buildRoute() {
if (!three || !scene) {
return;
}
clearRoute();
if (routePoints.length < 2) {
requestRender();
return;
}
const group = new three.Group();
const z = groundZ + 0.06;
const extent = cloudBounds
? Math.max(cloudBounds.max[0] - cloudBounds.min[0], cloudBounds.max[1] - cloudBounds.min[1])
: 20;
const tubeRadius = Math.min(Math.max(extent / 400, 0.06), 0.3);
const curvePoints: Vector3[] = [];
for (const point of routePoints) {
curvePoints.push(new three.Vector3(point.x, point.y, z));
}
// 只绘制路径本身,不画途经点/业务点
const curve = new three.CatmullRomCurve3(curvePoints, false, "catmullrom", 0.02);
const tubeGeometry = new three.TubeGeometry(curve, Math.max(routePoints.length * 12, 64), tubeRadius, 10, false);
const tubeMaterial = new three.MeshBasicMaterial({ color: 0x66d7ff });
group.add(new three.Mesh(tubeGeometry, tubeMaterial));
routeGroup = group;
routeBounds = {
min: [
Math.min(...routePoints.map((point) => point.x)),
Math.min(...routePoints.map((point) => point.y)),
z,
],
max: [
Math.max(...routePoints.map((point) => point.x)),
Math.max(...routePoints.map((point) => point.y)),
z,
],
};
scene.add(group);
requestRender();
}
/** 更新巡检路径数据(来自后端巡检任务节点),路径到达后如未手动操作过相机则重新取景 */
function setRoute(points: RoutePoint3D[]) {
routePoints = points;
buildRoute();
if (!cameraTouched && cloudBounds) {
frameCamera();
}
}
/** 更新机器人实时位姿标记 */
function setRobotPose(pose: RobotPose3D | null) {
robotPose = pose;
updateRobotMarker();
}
/** 重置相机到默认(沿长廊侧视)视角 */
function resetView() {
cameraTouched = false;
frameCamera();
}
onBeforeUnmount(dispose);
return {
loading,
error,
info,
loaded,
mount,
dispose,
loadPointCloud,
setRoute,
setRobotPose,
setKeyboardNavigation,
resetView,
};
}

View File

@@ -1554,6 +1554,104 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
// 地图卡标题行:左侧标题 + 右侧二维/三维视图切换
.card-map .map-card-head {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-right: 20px;
}
.card-map .map-card-head .title {
margin-bottom: 0;
}
.card-map .map-view-switch {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 6px;
}
.card-map .map-view-btn {
min-width: 70px;
height: 24px;
padding: 0 10px;
border: 1px solid rgba(86, 205, 255, 0.22);
background: rgba(0, 50, 135, 0.3);
color: rgba(184, 235, 255, 0.82);
font-size: 12px;
letter-spacing: 0.6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: rgba(102, 215, 255, 0.55);
color: #e6fbff;
}
&.active {
border-color: rgba(102, 215, 255, 0.7);
background: linear-gradient(90deg,
rgba(0, 96, 190, 0.55) 0%,
rgba(0, 60, 130, 0.35) 100%);
color: #eefcff;
box-shadow: 0 0 14px rgba(44, 164, 232, 0.35);
}
}
// 三维点云地图容器:铺满地图舞台,交互交给 three.js OrbitControls
.nav-map-3d {
position: absolute;
inset: 0;
z-index: 1;
cursor: grab;
canvas {
display: block;
width: 100%;
height: 100%;
}
&:active {
cursor: grabbing;
}
}
.nav-map-3d-info {
position: absolute;
z-index: 4;
left: 14px;
top: 12px;
padding: 4px 10px;
color: rgba(184, 235, 255, 0.88);
font-size: 11px;
letter-spacing: 0.6px;
background: rgba(4, 18, 38, 0.66);
border: 1px solid rgba(86, 205, 255, 0.16);
pointer-events: none;
}
// 放大预览下的三维操作提示:居中放在顶部,避开左上角信息与右上角按钮
.nav-map-3d-hint {
position: absolute;
z-index: 4;
left: 50%;
top: 12px;
transform: translateX(-50%);
padding: 4px 12px;
color: rgba(184, 235, 255, 0.8);
font-size: 12px;
letter-spacing: 0.8px;
background: rgba(4, 18, 38, 0.66);
border: 1px solid rgba(86, 205, 255, 0.16);
pointer-events: none;
white-space: nowrap;
}
.nav-map-header { .nav-map-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1675,11 +1773,15 @@
right: 14px; right: 14px;
top: 10px; top: 10px;
width: 30px; width: 30px;
height: 26px; // 与预览按钮同高,两者中心线对齐
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(86, 205, 255, 0.24); border: 1px solid rgba(86, 205, 255, 0.24);
background: rgba(9, 43, 82, 0.8); background: rgba(9, 43, 82, 0.8);
color: rgba(226, 250, 255, 0.92); color: rgba(226, 250, 255, 0.92);
font-size: 14px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
@@ -1958,10 +2060,12 @@
// 右移让位给右上角的预览/关闭按钮 // 右移让位给右上角的预览/关闭按钮
right: 52px; right: 52px;
top: 10px; top: 10px;
// 与右侧预览/关闭按钮同高同顶,保证三者中心线一致
height: 28px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 5px 7px; padding: 0 7px;
// 设计稿标签样式:半透明渐变底 + 细边框 + 外发光 + 轻微模糊 // 设计稿标签样式:半透明渐变底 + 细边框 + 外发光 + 轻微模糊
background: linear-gradient(90deg, background: linear-gradient(90deg,
rgba(0, 50, 135, 0.3) 0%, rgba(0, 50, 135, 0.3) 0%,
@@ -1982,6 +2086,13 @@
cursor: pointer; cursor: pointer;
} }
// 三维点云质量档位按钮的选中态(基础样式复用 .map-scale-controls button)
.map-scale-controls button.map-quality-btn.active {
border-color: rgba(102, 215, 255, 0.7);
background: rgba(0, 96, 190, 0.75);
color: #eefcff;
}
.map-scale-controls span { .map-scale-controls span {
min-width: 38px; min-width: 38px;
color: rgba(184, 235, 255, 0.88); color: rgba(184, 235, 255, 0.88);

View File

@@ -43,6 +43,7 @@ interface RosCarRecord {
liftHeight?: number; liftHeight?: number;
mapPgmUrl?: string; mapPgmUrl?: string;
mapYamlUrl?: string; mapYamlUrl?: string;
mapPcdUrl?: string;
dockX?: number; dockX?: number;
dockY?: number; dockY?: number;
dockYaw?: number; dockYaw?: number;
@@ -76,6 +77,7 @@ interface DeviceRecord {
infraredUrl?: string; infraredUrl?: string;
mapPgmUrl?: string; mapPgmUrl?: string;
mapYamlUrl?: string; mapYamlUrl?: string;
mapPcdUrl?: string;
poseX?: number; poseX?: number;
poseY?: number; poseY?: number;
poseYaw?: number; poseYaw?: number;
@@ -107,6 +109,8 @@ interface MapRecord {
pointCloudCount: number; pointCloudCount: number;
imageUrl?: string; imageUrl?: string;
configUrl?: string; configUrl?: string;
/** 点云 PCD 文件地址,供首页三维地图展示 */
pcdUrl?: string;
} }
interface RosMapMetaState { interface RosMapMetaState {
@@ -239,6 +243,9 @@ interface PlanFormState {
pointName: string; pointName: string;
pointType: PatrolPointType; pointType: PatrolPointType;
actions: PatrolAction[]; actions: PatrolAction[];
// 点位坐标,单位米,保存时换算回地图百分比
poseX: number;
poseY: number;
} }
interface BatchFormState { interface BatchFormState {
@@ -322,6 +329,8 @@ const planForm = reactive<PlanFormState>({
pointName: "", pointName: "",
pointType: "业务点", pointType: "业务点",
actions: [], actions: [],
poseX: 0,
poseY: 0,
}); });
const batchForm = reactive<BatchFormState>({ const batchForm = reactive<BatchFormState>({
@@ -404,10 +413,12 @@ const rosMapMeta = reactive<RosMapMetaState>({ ...defaultRosMapMeta });
const rosMapImage = new Image(); const rosMapImage = new Image();
const mapImageUploading = ref(false); const mapImageUploading = ref(false);
const mapConfigUploading = ref(false); const mapConfigUploading = ref(false);
const mapPcdUploading = ref(false);
const mapUploadMessage = ref(""); const mapUploadMessage = ref("");
const dockConfigMessage = ref(""); const dockConfigMessage = ref("");
const mapImageInputRef = ref<HTMLInputElement | null>(null); const mapImageInputRef = ref<HTMLInputElement | null>(null);
const mapConfigInputRef = ref<HTMLInputElement | null>(null); const mapConfigInputRef = ref<HTMLInputElement | null>(null);
const mapPcdInputRef = ref<HTMLInputElement | null>(null);
rosMapImage.onload = () => { rosMapImage.onload = () => {
rosMapMeta.width = rosMapImage.naturalWidth || rosMapMeta.width; rosMapMeta.width = rosMapImage.naturalWidth || rosMapMeta.width;
rosMapMeta.height = rosMapImage.naturalHeight || rosMapMeta.height; rosMapMeta.height = rosMapImage.naturalHeight || rosMapMeta.height;
@@ -595,7 +606,7 @@ function markCurrentMapUploaded(payload: Partial<MapRecord>) {
currentConfig.value.lastSyncedAt = nowString(); currentConfig.value.lastSyncedAt = nowString();
} }
// 调用小车地图地址接口,在保存时单独保存 PGM 和 YAML 文件 URL // 调用小车地图地址接口,在保存时单独保存 PGM、YAML 与 PCD 点云文件 URL
async function saveCurrentMapUrls() { async function saveCurrentMapUrls() {
if (!currentConfiguredDevice.value || !currentConfig.value) { if (!currentConfiguredDevice.value || !currentConfig.value) {
return; return;
@@ -606,8 +617,9 @@ async function saveCurrentMapUrls() {
); );
const mapPgmUrl = activeMap?.imageUrl || currentConfiguredDevice.value.mapPgmUrl; const mapPgmUrl = activeMap?.imageUrl || currentConfiguredDevice.value.mapPgmUrl;
const mapYamlUrl = activeMap?.configUrl || currentConfiguredDevice.value.mapYamlUrl; const mapYamlUrl = activeMap?.configUrl || currentConfiguredDevice.value.mapYamlUrl;
const mapPcdUrl = activeMap?.pcdUrl || currentConfiguredDevice.value.mapPcdUrl;
if (!mapPgmUrl && !mapYamlUrl) { if (!mapPgmUrl && !mapYamlUrl && !mapPcdUrl) {
return; return;
} }
@@ -615,10 +627,12 @@ async function saveCurrentMapUrls() {
id: currentConfiguredDevice.value.id, id: currentConfiguredDevice.value.id,
mapPgmUrl: mapPgmUrl || undefined, mapPgmUrl: mapPgmUrl || undefined,
mapYamlUrl: mapYamlUrl || undefined, mapYamlUrl: mapYamlUrl || undefined,
mapPcdUrl: mapPcdUrl || undefined,
}); });
currentConfiguredDevice.value.mapPgmUrl = mapPgmUrl || ""; currentConfiguredDevice.value.mapPgmUrl = mapPgmUrl || "";
currentConfiguredDevice.value.mapYamlUrl = mapYamlUrl || ""; currentConfiguredDevice.value.mapYamlUrl = mapYamlUrl || "";
currentConfiguredDevice.value.mapPcdUrl = mapPcdUrl || "";
} }
function triggerMapImageUpload() { function triggerMapImageUpload() {
@@ -629,6 +643,17 @@ function triggerMapConfigUpload() {
mapConfigInputRef.value?.click(); mapConfigInputRef.value?.click();
} }
function triggerMapPcdUpload() {
mapPcdInputRef.value?.click();
}
// 当前设备已绑定的点云文件名,用于弹窗内回显
const currentPcdFileName = computed(() => {
const pcdUrl = currentConfiguredDevice.value?.mapPcdUrl;
return pcdUrl ? pcdUrl.split("/").pop() || pcdUrl : "";
});
// 将地图显示恢复成本地默认地图,避免无 URL 的小车沿用上一次回显结果 // 将地图显示恢复成本地默认地图,避免无 URL 的小车沿用上一次回显结果
function resetRosMapToDefault() { function resetRosMapToDefault() {
Object.assign(rosMapMeta, { ...defaultRosMapMeta }); Object.assign(rosMapMeta, { ...defaultRosMapMeta });
@@ -643,6 +668,7 @@ function syncDeviceMapUrls(device: DeviceRecord, config: DeviceConfigState) {
if (activeMap) { if (activeMap) {
activeMap.imageUrl = device.mapPgmUrl || ""; activeMap.imageUrl = device.mapPgmUrl || "";
activeMap.configUrl = device.mapYamlUrl || ""; activeMap.configUrl = device.mapYamlUrl || "";
activeMap.pcdUrl = device.mapPcdUrl || "";
} }
} }
@@ -817,6 +843,53 @@ async function handleMapConfigFileChange(event: Event) {
} }
} }
// 上传点云 PCD 文件:仅保存文件地址,首页三维地图按该地址拉取点云渲染
async function handleMapPcdFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file || mapPcdUploading.value) {
return;
}
if (!/\.pcd$/i.test(file.name)) {
mapUploadMessage.value = "请选择 pcd 格式点云文件";
return;
}
// 后端单文件上传上限 300MB,提前拦截避免长时间等待后失败
if (file.size > 300 * 1024 * 1024) {
mapUploadMessage.value = "点云文件超过 300MB,请先降采样后再上传";
return;
}
mapPcdUploading.value = true;
mapUploadMessage.value = "点云 PCD 上传中...";
try {
const response = await uploadFile(file) as UploadFileResponse;
const uploadedUrl = getUploadedFileUrl(response);
if (!uploadedUrl) {
throw new Error("上传接口未返回文件 url");
}
markCurrentMapUploaded({ pcdUrl: uploadedUrl });
if (currentConfiguredDevice.value) {
currentConfiguredDevice.value.mapPcdUrl = uploadedUrl;
}
mapUploadMessage.value = "点云 PCD 上传成功,保存后生效";
} catch (error) {
console.error(error);
mapUploadMessage.value = "点云 PCD 上传失败,请确认文件完整";
} finally {
mapPcdUploading.value = false;
}
}
// 创建巡检动作记录,供点位弹窗动态新增动作使用 // 创建巡检动作记录,供点位弹窗动态新增动作使用
function buildPatrolAction(type: PatrolActionType, value = ""): PatrolAction { function buildPatrolAction(type: PatrolActionType, value = ""): PatrolAction {
patrolActionIdSeed += 1; patrolActionIdSeed += 1;
@@ -1124,6 +1197,7 @@ function buildDeviceMaps(device: DeviceRecord): MapRecord[] {
pointCloudCount: 1648, pointCloudCount: 1648,
imageUrl: device.mapPgmUrl, imageUrl: device.mapPgmUrl,
configUrl: device.mapYamlUrl, configUrl: device.mapYamlUrl,
pcdUrl: device.mapPcdUrl,
}, },
{ {
id: `${device.project}-equipment`, id: `${device.project}-equipment`,
@@ -1239,6 +1313,7 @@ function normalizeDeviceRecord(car: RosCarRecord, index: number): DeviceRecord {
infraredUrl: car.cameraWebrtcUrl2 || "", infraredUrl: car.cameraWebrtcUrl2 || "",
mapPgmUrl: car.mapPgmUrl || "", mapPgmUrl: car.mapPgmUrl || "",
mapYamlUrl: car.mapYamlUrl || "", mapYamlUrl: car.mapYamlUrl || "",
mapPcdUrl: car.mapPcdUrl || "",
poseX: car.poseX, poseX: car.poseX,
poseY: car.poseY, poseY: car.poseY,
poseYaw: car.poseYaw, poseYaw: car.poseYaw,
@@ -1369,6 +1444,8 @@ function resetPlanForm() {
planForm.pointName = ""; planForm.pointName = "";
planForm.pointType = "业务点"; planForm.pointType = "业务点";
planForm.actions = []; planForm.actions = [];
planForm.poseX = 0;
planForm.poseY = 0;
} }
function openCreateDialog() { function openCreateDialog() {
@@ -2863,9 +2940,13 @@ function loadPlan(point: PatrolPoint) {
const pointIndex = activeRoundPoints.value.findIndex((item) => item.id === normalizedPoint.id); const pointIndex = activeRoundPoints.value.findIndex((item) => item.id === normalizedPoint.id);
const roundActions = getPatrolPointRoundActions(normalizedPoint, activeRound.value.id); const roundActions = getPatrolPointRoundActions(normalizedPoint, activeRound.value.id);
const pose = mapPercentToRosPose(normalizedPoint);
editingPointId.value = normalizedPoint.id; editingPointId.value = normalizedPoint.id;
planForm.pointName = buildAutoPointName(normalizedPoint.type, Math.max(0, pointIndex)); planForm.pointName = buildAutoPointName(normalizedPoint.type, Math.max(0, pointIndex));
planForm.pointType = normalizedPoint.type; planForm.pointType = normalizedPoint.type;
planForm.poseX = pose.poseX;
planForm.poseY = pose.poseY;
planForm.actions = normalizedPoint.type === "业务点" planForm.actions = normalizedPoint.type === "业务点"
? clonePatrolActions(roundActions) ? clonePatrolActions(roundActions)
: []; : [];
@@ -2945,12 +3026,19 @@ function savePlan() {
} }
const activePlanId = currentConfig.value.activePlanId; const activePlanId = currentConfig.value.activePlanId;
// 将弹窗中编辑的米制坐标换算回地图百分比坐标并做边界钳制
const nextPosition = rosPoseToMapPercent(
Number(planForm.poseX) || 0,
Number(planForm.poseY) || 0,
);
currentConfig.value.points = currentConfig.value.points.map((point) => currentConfig.value.points = currentConfig.value.points.map((point) =>
point.id === editingPointId.value point.id === editingPointId.value
? { ? {
...point, ...point,
name: buildAutoPointName(planForm.pointType, pointIndex), name: buildAutoPointName(planForm.pointType, pointIndex),
x: nextPosition.x,
y: nextPosition.y,
type: planForm.pointType, type: planForm.pointType,
direction: "正向", direction: "正向",
liftHeight: getPatrolActionValue(actions, "升降柱高度"), liftHeight: getPatrolActionValue(actions, "升降柱高度"),
@@ -3234,6 +3322,8 @@ watch(
@change="handleMapImageFileChange" /> @change="handleMapImageFileChange" />
<input ref="mapConfigInputRef" accept=".yaml,.yml" class="hidden-file-input" type="file" <input ref="mapConfigInputRef" accept=".yaml,.yml" class="hidden-file-input" type="file"
@change="handleMapConfigFileChange" /> @change="handleMapConfigFileChange" />
<input ref="mapPcdInputRef" accept=".pcd" class="hidden-file-input" type="file"
@change="handleMapPcdFileChange" />
<button class="page-action-btn ghost" :disabled="mapImageUploading" type="button" <button class="page-action-btn ghost" :disabled="mapImageUploading" type="button"
@click="triggerMapImageUpload"> @click="triggerMapImageUpload">
{{ mapImageUploading ? "PGM 上传中..." : "上传地图 PGM" }} {{ mapImageUploading ? "PGM 上传中..." : "上传地图 PGM" }}
@@ -3242,6 +3332,10 @@ watch(
@click="triggerMapConfigUpload"> @click="triggerMapConfigUpload">
{{ mapConfigUploading ? "配置上传中..." : "上传地图配置" }} {{ mapConfigUploading ? "配置上传中..." : "上传地图配置" }}
</button> </button>
<button class="page-action-btn ghost" :disabled="mapPcdUploading" type="button"
@click="triggerMapPcdUpload">
{{ mapPcdUploading ? "PCD 上传中..." : "上传点云 PCD" }}
</button>
<!-- <button <!-- <button
class="page-action-btn primary" class="page-action-btn primary"
type="button" type="button"
@@ -3260,6 +3354,10 @@ watch(
{{ mapUploadMessage }} {{ mapUploadMessage }}
</div> </div>
<div class="map-upload-message">
首页三维地图点云:{{ currentPcdFileName || "未上传" }}
</div>
<div class="editor-toolbar"> <div class="editor-toolbar">
<button v-for="mode in editorModes" :key="mode.id" :class="[ <button v-for="mode in editorModes" :key="mode.id" :class="[
'editor-tool-btn', 'editor-tool-btn',
@@ -3480,6 +3578,14 @@ watch(
</option> </option>
</select> </select>
</label> </label>
<label class="config-field">
<span>X 坐标 (m)</span>
<input v-model.number="planForm.poseX" step="0.001" type="number" />
</label>
<label class="config-field">
<span>Y 坐标 (m)</span>
<input v-model.number="planForm.poseY" step="0.001" type="number" />
</label>
<div v-if="planForm.pointType === '业务点'" class="plan-action-section wide"> <div v-if="planForm.pointType === '业务点'" class="plan-action-section wide">
<div class="plan-action-header"> <div class="plan-action-header">
<span>动作配置</span> <span>动作配置</span>

View File

@@ -29,6 +29,7 @@ import { getToken } from "@/utils/auth";
import { parseRosMapYaml } from "@/utils/rosMapYaml"; import { parseRosMapYaml } from "@/utils/rosMapYaml";
import { BASE_URL } from "@/utils/request"; import { BASE_URL } from "@/utils/request";
import { getRecentAlertPhoto } from "../shared/alertPhotos"; import { getRecentAlertPhoto } from "../shared/alertPhotos";
import usePointCloud3D from "../hooks/usePointCloud3D";
// 地图上的机器人标记图标(设计稿 巡检机器人_03,8 倍图,放大时不易模糊) // 地图上的机器人标记图标(设计稿 巡检机器人_03,8 倍图,放大时不易模糊)
import robotIcon from "@/assets/robot-marker-8x.png"; import robotIcon from "@/assets/robot-marker-8x.png";
// 未配置地图时展示的默认隧道示意图 // 未配置地图时展示的默认隧道示意图
@@ -41,6 +42,15 @@ type DriveMode = "阿克曼" | "旋转" | "横移";
type CameraChannelKey = "white" | "infrared"; type CameraChannelKey = "white" | "infrared";
const cameraChannelKeys: CameraChannelKey[] = ["white", "infrared"]; const cameraChannelKeys: CameraChannelKey[] = ["white", "infrared"];
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中"; 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 ControlMessageType = "cmd_vel" | "ptz_control" | "lift_control";
type PtzAction = "up" | "down" | "left" | "right" | "stop"; type PtzAction = "up" | "down" | "left" | "right" | "stop";
@@ -57,6 +67,7 @@ interface RosCarRecord {
poseYaw?: number; poseYaw?: number;
mapPgmUrl?: string; mapPgmUrl?: string;
mapYamlUrl?: string; mapYamlUrl?: string;
mapPcdUrl?: string;
cameraWebrtcUrl?: string; cameraWebrtcUrl?: string;
cameraWebrtcUrl2?: string; cameraWebrtcUrl2?: string;
liftHeight?: number; liftHeight?: number;
@@ -88,6 +99,7 @@ interface CarStatusPushData {
poseYaw?: number; poseYaw?: number;
mapPgmUrl?: string; mapPgmUrl?: string;
mapYamlUrl?: string; mapYamlUrl?: string;
mapPcdUrl?: string;
status?: number; status?: number;
signal?: string; signal?: string;
dockStatus?: string; dockStatus?: string;
@@ -136,6 +148,7 @@ interface DeviceItem {
poseYaw?: number; poseYaw?: number;
mapPgmUrl: string; mapPgmUrl: string;
mapYamlUrl: string; mapYamlUrl: string;
mapPcdUrl: string;
whiteLightUrl: string; whiteLightUrl: string;
infraredUrl: string; infraredUrl: string;
dockStatus?: string; dockStatus?: string;
@@ -251,6 +264,7 @@ const emptyDevice: DeviceItem = {
poseYaw: undefined, poseYaw: undefined,
mapPgmUrl: "", mapPgmUrl: "",
mapYamlUrl: "", mapYamlUrl: "",
mapPcdUrl: "",
whiteLightUrl: "", whiteLightUrl: "",
infraredUrl: "", infraredUrl: "",
}; };
@@ -314,6 +328,7 @@ function normalizeDeviceRecord(car: RosCarRecord, index: number): DeviceItem {
poseYaw: car.poseYaw, poseYaw: car.poseYaw,
mapPgmUrl: car.mapPgmUrl || "", mapPgmUrl: car.mapPgmUrl || "",
mapYamlUrl: car.mapYamlUrl || "", mapYamlUrl: car.mapYamlUrl || "",
mapPcdUrl: car.mapPcdUrl || "",
whiteLightUrl: car.cameraWebrtcUrl || "", whiteLightUrl: car.cameraWebrtcUrl || "",
infraredUrl: car.cameraWebrtcUrl2 || "", infraredUrl: car.cameraWebrtcUrl2 || "",
dockStatus: car.dockStatus, dockStatus: car.dockStatus,
@@ -517,6 +532,24 @@ const mapDragState = ref({
originY: 0, originY: 0,
}); });
const rosMap = ref<RosMapState | null>(null); 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 mapLoading = ref(false);
const mapError = ref(""); const mapError = ref("");
const routeNodes = ref<InspectionRouteNode[]>([]); const routeNodes = ref<InspectionRouteNode[]>([]);
@@ -667,6 +700,8 @@ const currentScenario = computed(() => navScenario);
const hasRobotPose = computed( const hasRobotPose = computed(
() => typeof currentRobot.value.poseX === "number" && typeof currentRobot.value.poseY === "number", () => typeof currentRobot.value.poseX === "number" && typeof currentRobot.value.poseY === "number",
); );
// 当前机器人是否已在后台配置点云文件,未配置时三维视图给出提示
const hasPointCloud = computed(() => Boolean(currentRobot.value.mapPcdUrl));
// 回充状态文案:由 charging_dock 节点的 /dock_status 上报,未上报时给出中性提示 // 回充状态文案:由 charging_dock 节点的 /dock_status 上报,未上报时给出中性提示
const chargingStatusText = computed(() => { const chargingStatusText = computed(() => {
const device = currentRobot.value; const device = currentRobot.value;
@@ -1060,6 +1095,7 @@ function applyCarStatusPush(data: CarStatusPushData) {
poseYaw: typeof data.poseYaw === "number" ? data.poseYaw : currentDevice.poseYaw, poseYaw: typeof data.poseYaw === "number" ? data.poseYaw : currentDevice.poseYaw,
mapPgmUrl: data.mapPgmUrl || currentDevice.mapPgmUrl, mapPgmUrl: data.mapPgmUrl || currentDevice.mapPgmUrl,
mapYamlUrl: data.mapYamlUrl || currentDevice.mapYamlUrl, mapYamlUrl: data.mapYamlUrl || currentDevice.mapYamlUrl,
mapPcdUrl: data.mapPcdUrl || currentDevice.mapPcdUrl,
dockStatus: typeof data.dockStatus === "string" ? data.dockStatus : currentDevice.dockStatus, dockStatus: typeof data.dockStatus === "string" ? data.dockStatus : currentDevice.dockStatus,
dockActive: typeof data.dockActive === "boolean" ? data.dockActive : currentDevice.dockActive, dockActive: typeof data.dockActive === "boolean" ? data.dockActive : currentDevice.dockActive,
}; };
@@ -1456,6 +1492,55 @@ watch(
{ immediate: true }, { 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]) { function switchAlertTab(tab: (typeof alertTabs)[number]) {
activeAlertTab.value = tab; activeAlertTab.value = tab;
fetchRecentAlerts(); fetchRecentAlerts();
@@ -1748,7 +1833,20 @@ function getStatusClass(status: DeviceItem["status"]) {
<div class="middle"> <div class="middle">
<!-- card- 前缀复用与其他面板一致的卡片标题样式 --> <!-- card- 前缀复用与其他面板一致的卡片标题样式 -->
<div class="card-map nav-map-panel"> <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 v-if="mapPreviewVisible" class="map-preview-mask" @click="closeMapPreview"></div>
<div :class="['nav-map-stage', { 'preview-mode': mapPreviewVisible }]"> <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" /> stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</button> </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> </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" @wheel="handleMapWheel" @pointerdown="startMapDrag" @pointermove="moveMapDrag" @pointerup="stopMapDrag"
@pointerleave="stopMapDrag" @pointercancel="stopMapDrag"> @pointerleave="stopMapDrag" @pointercancel="stopMapDrag">
<defs> <defs>
@@ -1805,7 +1907,7 @@ function getStatusClass(status: DeviceItem["status"]) {
</g> </g>
</svg> </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" /> <image class="empty-map-image" :href="emptyMapImage" x="0" y="0" width="1398" height="467" />
<g class="robot-layer" transform="translate(1216, 266)"> <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" /> <image class="robot-icon" :href="robotIcon" x="-14" y="-8" width="28" height="15" />
</g> </g>
</svg> </svg>
<div v-if="mapLoading" class="map-state-tip">地图加载中...</div> <!-- 三维点云地图:WebGL 容器常驻,仅切换显示,避免来回切换时反复重建场景 -->
<div v-else-if="mapError" class="map-state-tip">{{ mapError }}</div> <div v-show="mapViewMode === '3d'" ref="map3dContainerRef" class="nav-map-3d"></div>
<div v-else-if="routeLoading" class="map-state-tip">路线加载中...</div> <div v-if="mapViewMode === '3d' && pointCloudInfo" class="nav-map-3d-info">
<div v-else-if="routeError" class="map-state-tip">{{ routeError }}</div> {{ 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-left">ROS 实时地图</div>
<div class="nav-map-overlay top-right">真实坐标路径跟踪</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> <button type="button" @click="changeMapScale(-0.05)">-</button>
<span>{{ Math.round(mapScale * 100) }}%</span> <span>{{ Math.round(mapScale * 100) }}%</span>
<button type="button" @click="changeMapScale(0.05)">+</button> <button type="button" @click="changeMapScale(0.05)">+</button>
<button type="button" @click="resetMapScale">重置</button> <button type="button" @click="resetMapScale">重置</button>
</div> </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"> <div class="nav-map-overlay bottom-left">
机器人编号:{{ currentRobot.id }} / {{ currentRobot.task }} 机器人编号:{{ currentRobot.id }} / {{ currentRobot.task }}
</div> </div>