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

862 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}