- 设备管理机器人配置弹窗新增点云 PCD 上传,地址保存到 ros_car.mapPcdUrl - 首页地图新增 2D/3D 切换,三维视图动态加载 three.js 渲染 PCD 点云 - 三维视图:按 z 直方图估算地面使小车与路径贴地、四轮小车模型、地面绘制巡检路径、放大预览支持 WASD 移动视角 - 地面按占用栅格生成连续平面,并剔除地面带内的点云,避免重复渲染 - PCD 解析支持 ascii/binary/binary_compressed,按流畅/标准/精细三档均匀抽稀 - 二维放大保留原拖拽平移与滚轮缩放
862 lines
26 KiB
TypeScript
862 lines
26 KiB
TypeScript
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,
|
||
};
|
||
}
|