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