diff --git a/package-lock.json b/package-lock.json index de5c2c7..561f5aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7d2a7c2..23d8329 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/api/rosCar.ts b/src/api/rosCar.ts index 91848c0..1d71493 100644 --- a/src/api/rosCar.ts +++ b/src/api/rosCar.ts @@ -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>({ url: "/admin/rosCar/updateMapUrls", diff --git a/src/utils/pcd.ts b/src/utils/pcd.ts new file mode 100644 index 0000000..ce3cedd --- /dev/null +++ b/src/utils/pcd.ts @@ -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 = {}; + const fieldStrides: Record = {}; + 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), + }; +} diff --git a/src/views/dashboard1/hooks/usePointCloud3D.ts b/src/views/dashboard1/hooks/usePointCloud3D.ts new file mode 100644 index 0000000..33c9f22 --- /dev/null +++ b/src/views/dashboard1/hooks/usePointCloud3D.ts @@ -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) { + 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(); + 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, + }; +} diff --git a/src/views/dashboard1/index.less b/src/views/dashboard1/index.less index 648e6b1..8070714 100644 --- a/src/views/dashboard1/index.less +++ b/src/views/dashboard1/index.less @@ -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); diff --git a/src/views/dashboard1/pages/EquipmentManagementPage.vue b/src/views/dashboard1/pages/EquipmentManagementPage.vue index 27fb158..3d929ed 100644 --- a/src/views/dashboard1/pages/EquipmentManagementPage.vue +++ b/src/views/dashboard1/pages/EquipmentManagementPage.vue @@ -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({ pointName: "", pointType: "业务点", actions: [], + poseX: 0, + poseY: 0, }); const batchForm = reactive({ @@ -404,10 +413,12 @@ const rosMapMeta = reactive({ ...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(null); const mapConfigInputRef = ref(null); +const mapPcdInputRef = ref(null); rosMapImage.onload = () => { rosMapMeta.width = rosMapImage.naturalWidth || rosMapMeta.width; rosMapMeta.height = rosMapImage.naturalHeight || rosMapMeta.height; @@ -595,7 +606,7 @@ function markCurrentMapUploaded(payload: Partial) { 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" /> + +