feat(dashboard1): 首页地图支持三维点云展示,机器人配置支持上传 PCD

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

View File

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