feat(dashboard1): 首页地图支持三维点云展示,机器人配置支持上传 PCD
- 设备管理机器人配置弹窗新增点云 PCD 上传,地址保存到 ros_car.mapPcdUrl - 首页地图新增 2D/3D 切换,三维视图动态加载 three.js 渲染 PCD 点云 - 三维视图:按 z 直方图估算地面使小车与路径贴地、四轮小车模型、地面绘制巡检路径、放大预览支持 WASD 移动视角 - 地面按占用栅格生成连续平面,并剔除地面带内的点云,避免重复渲染 - PCD 解析支持 ascii/binary/binary_compressed,按流畅/标准/精细三档均匀抽稀 - 二维放大保留原拖拽平移与滚轮缩放
This commit is contained in:
65
package-lock.json
generated
65
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^20.4.4",
|
||||
"@types/three": "^0.180.0",
|
||||
"axios": "^1.18.1",
|
||||
"echarts": "^5.4.3",
|
||||
"echarts-liquidfill": "^3.1.0",
|
||||
@@ -16,6 +17,7 @@
|
||||
"ol": "^7.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"prettier": "^3.0.0",
|
||||
"three": "^0.180.0",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
@@ -444,6 +446,12 @@
|
||||
"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": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -905,6 +913,12 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -956,6 +970,33 @@
|
||||
"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": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vicons/fluent/-/fluent-0.12.0.tgz",
|
||||
@@ -1257,6 +1298,12 @@
|
||||
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
|
||||
"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": {
|
||||
"version": "8.18.0",
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -2572,6 +2625,12 @@
|
||||
"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": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
|
||||
@@ -3215,6 +3274,12 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^20.4.4",
|
||||
"@types/three": "^0.180.0",
|
||||
"axios": "^1.18.1",
|
||||
"echarts": "^5.4.3",
|
||||
"echarts-liquidfill": "^3.1.0",
|
||||
@@ -18,6 +19,7 @@
|
||||
"ol": "^7.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"prettier": "^3.0.0",
|
||||
"three": "^0.180.0",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface RosCar {
|
||||
mapPgmUrl?: string;
|
||||
/** 地图 YAML 文件 URL 地址 */
|
||||
mapYamlUrl?: string;
|
||||
/** 地图 PCD 点云文件 URL 地址(前端三维地图展示用) */
|
||||
mapPcdUrl?: string;
|
||||
/** 升降柱高度 */
|
||||
liftHeight?: number;
|
||||
/** 升降柱状态 */
|
||||
@@ -307,6 +309,7 @@ export function updateRosCarMapUrls(data: {
|
||||
id: string;
|
||||
mapPgmUrl?: string;
|
||||
mapYamlUrl?: string;
|
||||
mapPcdUrl?: string;
|
||||
}) {
|
||||
return request<ApiResult<null>>({
|
||||
url: "/admin/rosCar/updateMapUrls",
|
||||
|
||||
574
src/utils/pcd.ts
Normal file
574
src/utils/pcd.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
861
src/views/dashboard1/hooks/usePointCloud3D.ts
Normal file
861
src/views/dashboard1/hooks/usePointCloud3D.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1554,6 +1554,104 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1675,11 +1773,15 @@
|
||||
right: 14px;
|
||||
top: 10px;
|
||||
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);
|
||||
background: rgba(9, 43, 82, 0.8);
|
||||
color: rgba(226, 250, 255, 0.92);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1958,10 +2060,12 @@
|
||||
// 右移让位给右上角的预览/关闭按钮
|
||||
right: 52px;
|
||||
top: 10px;
|
||||
// 与右侧预览/关闭按钮同高同顶,保证三者中心线一致
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 7px;
|
||||
padding: 0 7px;
|
||||
// 设计稿标签样式:半透明渐变底 + 细边框 + 外发光 + 轻微模糊
|
||||
background: linear-gradient(90deg,
|
||||
rgba(0, 50, 135, 0.3) 0%,
|
||||
@@ -1982,6 +2086,13 @@
|
||||
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 {
|
||||
min-width: 38px;
|
||||
color: rgba(184, 235, 255, 0.88);
|
||||
|
||||
@@ -43,6 +43,7 @@ interface RosCarRecord {
|
||||
liftHeight?: number;
|
||||
mapPgmUrl?: string;
|
||||
mapYamlUrl?: string;
|
||||
mapPcdUrl?: string;
|
||||
dockX?: number;
|
||||
dockY?: number;
|
||||
dockYaw?: number;
|
||||
@@ -76,6 +77,7 @@ interface DeviceRecord {
|
||||
infraredUrl?: string;
|
||||
mapPgmUrl?: string;
|
||||
mapYamlUrl?: string;
|
||||
mapPcdUrl?: string;
|
||||
poseX?: number;
|
||||
poseY?: number;
|
||||
poseYaw?: number;
|
||||
@@ -107,6 +109,8 @@ interface MapRecord {
|
||||
pointCloudCount: number;
|
||||
imageUrl?: string;
|
||||
configUrl?: string;
|
||||
/** 点云 PCD 文件地址,供首页三维地图展示 */
|
||||
pcdUrl?: string;
|
||||
}
|
||||
|
||||
interface RosMapMetaState {
|
||||
@@ -239,6 +243,9 @@ interface PlanFormState {
|
||||
pointName: string;
|
||||
pointType: PatrolPointType;
|
||||
actions: PatrolAction[];
|
||||
// 点位坐标,单位米,保存时换算回地图百分比
|
||||
poseX: number;
|
||||
poseY: number;
|
||||
}
|
||||
|
||||
interface BatchFormState {
|
||||
@@ -322,6 +329,8 @@ const planForm = reactive<PlanFormState>({
|
||||
pointName: "",
|
||||
pointType: "业务点",
|
||||
actions: [],
|
||||
poseX: 0,
|
||||
poseY: 0,
|
||||
});
|
||||
|
||||
const batchForm = reactive<BatchFormState>({
|
||||
@@ -404,10 +413,12 @@ const rosMapMeta = reactive<RosMapMetaState>({ ...defaultRosMapMeta });
|
||||
const rosMapImage = new Image();
|
||||
const mapImageUploading = ref(false);
|
||||
const mapConfigUploading = ref(false);
|
||||
const mapPcdUploading = ref(false);
|
||||
const mapUploadMessage = ref("");
|
||||
const dockConfigMessage = ref("");
|
||||
const mapImageInputRef = ref<HTMLInputElement | null>(null);
|
||||
const mapConfigInputRef = ref<HTMLInputElement | null>(null);
|
||||
const mapPcdInputRef = ref<HTMLInputElement | null>(null);
|
||||
rosMapImage.onload = () => {
|
||||
rosMapMeta.width = rosMapImage.naturalWidth || rosMapMeta.width;
|
||||
rosMapMeta.height = rosMapImage.naturalHeight || rosMapMeta.height;
|
||||
@@ -595,7 +606,7 @@ function markCurrentMapUploaded(payload: Partial<MapRecord>) {
|
||||
currentConfig.value.lastSyncedAt = nowString();
|
||||
}
|
||||
|
||||
// 调用小车地图地址接口,在保存时单独保存 PGM 和 YAML 文件 URL
|
||||
// 调用小车地图地址接口,在保存时单独保存 PGM、YAML 与 PCD 点云文件 URL
|
||||
async function saveCurrentMapUrls() {
|
||||
if (!currentConfiguredDevice.value || !currentConfig.value) {
|
||||
return;
|
||||
@@ -606,8 +617,9 @@ async function saveCurrentMapUrls() {
|
||||
);
|
||||
const mapPgmUrl = activeMap?.imageUrl || currentConfiguredDevice.value.mapPgmUrl;
|
||||
const mapYamlUrl = activeMap?.configUrl || currentConfiguredDevice.value.mapYamlUrl;
|
||||
const mapPcdUrl = activeMap?.pcdUrl || currentConfiguredDevice.value.mapPcdUrl;
|
||||
|
||||
if (!mapPgmUrl && !mapYamlUrl) {
|
||||
if (!mapPgmUrl && !mapYamlUrl && !mapPcdUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -615,10 +627,12 @@ async function saveCurrentMapUrls() {
|
||||
id: currentConfiguredDevice.value.id,
|
||||
mapPgmUrl: mapPgmUrl || undefined,
|
||||
mapYamlUrl: mapYamlUrl || undefined,
|
||||
mapPcdUrl: mapPcdUrl || undefined,
|
||||
});
|
||||
|
||||
currentConfiguredDevice.value.mapPgmUrl = mapPgmUrl || "";
|
||||
currentConfiguredDevice.value.mapYamlUrl = mapYamlUrl || "";
|
||||
currentConfiguredDevice.value.mapPcdUrl = mapPcdUrl || "";
|
||||
}
|
||||
|
||||
function triggerMapImageUpload() {
|
||||
@@ -629,6 +643,17 @@ function triggerMapConfigUpload() {
|
||||
mapConfigInputRef.value?.click();
|
||||
}
|
||||
|
||||
function triggerMapPcdUpload() {
|
||||
mapPcdInputRef.value?.click();
|
||||
}
|
||||
|
||||
// 当前设备已绑定的点云文件名,用于弹窗内回显
|
||||
const currentPcdFileName = computed(() => {
|
||||
const pcdUrl = currentConfiguredDevice.value?.mapPcdUrl;
|
||||
|
||||
return pcdUrl ? pcdUrl.split("/").pop() || pcdUrl : "";
|
||||
});
|
||||
|
||||
// 将地图显示恢复成本地默认地图,避免无 URL 的小车沿用上一次回显结果
|
||||
function resetRosMapToDefault() {
|
||||
Object.assign(rosMapMeta, { ...defaultRosMapMeta });
|
||||
@@ -643,6 +668,7 @@ function syncDeviceMapUrls(device: DeviceRecord, config: DeviceConfigState) {
|
||||
if (activeMap) {
|
||||
activeMap.imageUrl = device.mapPgmUrl || "";
|
||||
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 {
|
||||
patrolActionIdSeed += 1;
|
||||
@@ -1124,6 +1197,7 @@ function buildDeviceMaps(device: DeviceRecord): MapRecord[] {
|
||||
pointCloudCount: 1648,
|
||||
imageUrl: device.mapPgmUrl,
|
||||
configUrl: device.mapYamlUrl,
|
||||
pcdUrl: device.mapPcdUrl,
|
||||
},
|
||||
{
|
||||
id: `${device.project}-equipment`,
|
||||
@@ -1239,6 +1313,7 @@ function normalizeDeviceRecord(car: RosCarRecord, index: number): DeviceRecord {
|
||||
infraredUrl: car.cameraWebrtcUrl2 || "",
|
||||
mapPgmUrl: car.mapPgmUrl || "",
|
||||
mapYamlUrl: car.mapYamlUrl || "",
|
||||
mapPcdUrl: car.mapPcdUrl || "",
|
||||
poseX: car.poseX,
|
||||
poseY: car.poseY,
|
||||
poseYaw: car.poseYaw,
|
||||
@@ -1369,6 +1444,8 @@ function resetPlanForm() {
|
||||
planForm.pointName = "";
|
||||
planForm.pointType = "业务点";
|
||||
planForm.actions = [];
|
||||
planForm.poseX = 0;
|
||||
planForm.poseY = 0;
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
@@ -2863,9 +2940,13 @@ function loadPlan(point: PatrolPoint) {
|
||||
const pointIndex = activeRoundPoints.value.findIndex((item) => item.id === normalizedPoint.id);
|
||||
const roundActions = getPatrolPointRoundActions(normalizedPoint, activeRound.value.id);
|
||||
|
||||
const pose = mapPercentToRosPose(normalizedPoint);
|
||||
|
||||
editingPointId.value = normalizedPoint.id;
|
||||
planForm.pointName = buildAutoPointName(normalizedPoint.type, Math.max(0, pointIndex));
|
||||
planForm.pointType = normalizedPoint.type;
|
||||
planForm.poseX = pose.poseX;
|
||||
planForm.poseY = pose.poseY;
|
||||
planForm.actions = normalizedPoint.type === "业务点"
|
||||
? clonePatrolActions(roundActions)
|
||||
: [];
|
||||
@@ -2945,12 +3026,19 @@ function savePlan() {
|
||||
}
|
||||
|
||||
const activePlanId = currentConfig.value.activePlanId;
|
||||
// 将弹窗中编辑的米制坐标换算回地图百分比坐标并做边界钳制
|
||||
const nextPosition = rosPoseToMapPercent(
|
||||
Number(planForm.poseX) || 0,
|
||||
Number(planForm.poseY) || 0,
|
||||
);
|
||||
|
||||
currentConfig.value.points = currentConfig.value.points.map((point) =>
|
||||
point.id === editingPointId.value
|
||||
? {
|
||||
...point,
|
||||
name: buildAutoPointName(planForm.pointType, pointIndex),
|
||||
x: nextPosition.x,
|
||||
y: nextPosition.y,
|
||||
type: planForm.pointType,
|
||||
direction: "正向",
|
||||
liftHeight: getPatrolActionValue(actions, "升降柱高度"),
|
||||
@@ -3234,6 +3322,8 @@ watch(
|
||||
@change="handleMapImageFileChange" />
|
||||
<input ref="mapConfigInputRef" accept=".yaml,.yml" class="hidden-file-input" type="file"
|
||||
@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"
|
||||
@click="triggerMapImageUpload">
|
||||
{{ mapImageUploading ? "PGM 上传中..." : "上传地图 PGM" }}
|
||||
@@ -3242,6 +3332,10 @@ watch(
|
||||
@click="triggerMapConfigUpload">
|
||||
{{ mapConfigUploading ? "配置上传中..." : "上传地图配置" }}
|
||||
</button>
|
||||
<button class="page-action-btn ghost" :disabled="mapPcdUploading" type="button"
|
||||
@click="triggerMapPcdUpload">
|
||||
{{ mapPcdUploading ? "PCD 上传中..." : "上传点云 PCD" }}
|
||||
</button>
|
||||
<!-- <button
|
||||
class="page-action-btn primary"
|
||||
type="button"
|
||||
@@ -3260,6 +3354,10 @@ watch(
|
||||
{{ mapUploadMessage }}
|
||||
</div>
|
||||
|
||||
<div class="map-upload-message">
|
||||
首页三维地图点云:{{ currentPcdFileName || "未上传" }}
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<button v-for="mode in editorModes" :key="mode.id" :class="[
|
||||
'editor-tool-btn',
|
||||
@@ -3480,6 +3578,14 @@ watch(
|
||||
</option>
|
||||
</select>
|
||||
</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 class="plan-action-header">
|
||||
<span>动作配置</span>
|
||||
|
||||
@@ -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="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>
|
||||
<!-- 三维点云地图: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