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