|
|
@@ -0,0 +1,1085 @@
|
|
|
+<template>
|
|
|
+ <div class="vslam-view-container" v-loading="!viewerReady" element-loading-text="正在初始化 3D 渲染器..." element-loading-background="rgba(0, 0, 0, 0.8)">
|
|
|
+ <!-- Potree 容器 -->
|
|
|
+ <div id="potree_render_area" class="potree-container"></div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+/* eslint-disable */
|
|
|
+import * as THREE from 'three'
|
|
|
+import { mapState, mapActions } from 'vuex'
|
|
|
+
|
|
|
+// 工具类
|
|
|
+import Utils from '../utils/Utils'
|
|
|
+import createIntersectPointsMesh, { resetIntersectPointsState } from '../utils/IntersectPointsMesh'
|
|
|
+import {
|
|
|
+ CreateGroundMesh,
|
|
|
+ CreateFlowmark,
|
|
|
+ CreateObjectBox,
|
|
|
+ CreateShapMesh,
|
|
|
+ CreatePlaybackeMesh
|
|
|
+} from '../utils/CreateMesh'
|
|
|
+
|
|
|
+// API
|
|
|
+import {
|
|
|
+ urlVSlamStatistics,
|
|
|
+ urlKeyframePointcloud,
|
|
|
+ urlKeyframeTrans,
|
|
|
+ parsePointcloudData,
|
|
|
+ parseTransformData
|
|
|
+} from '@/api/map/vslam'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'VSlamView',
|
|
|
+ props: {
|
|
|
+ mapName: {
|
|
|
+ type: String,
|
|
|
+ required: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ viewerReady: false,
|
|
|
+ viewer: null,
|
|
|
+
|
|
|
+ // Web Workers
|
|
|
+ statisticsWorker: null,
|
|
|
+ keyframeWorker: null,
|
|
|
+ keyframeTransWorker: null,
|
|
|
+
|
|
|
+ // Three.js 对象
|
|
|
+ robotObj: null,
|
|
|
+ modelOffset: [0, 0, 0],
|
|
|
+ prevRobotPosition: null,
|
|
|
+ currentCamera: new THREE.Vector3(0, 0, 0),
|
|
|
+ currentPlane: null,
|
|
|
+
|
|
|
+ // 点云数据
|
|
|
+ cloudArry: [],
|
|
|
+ gTransArry: [],
|
|
|
+ pointsGroup: new THREE.Group(),
|
|
|
+ newPointsGroup: new THREE.Group(),
|
|
|
+ routeGroup: new THREE.Group(),
|
|
|
+ intersectingIndexs: [],
|
|
|
+ showIndexs: [],
|
|
|
+ maxFrameNumber: 100,
|
|
|
+
|
|
|
+ // 地面网格
|
|
|
+ groundMeshFunc: null,
|
|
|
+
|
|
|
+ // 可视化对象
|
|
|
+ visualObjectMeshs: [],
|
|
|
+ visualAnnotations: [],
|
|
|
+ planTrajectory: null,
|
|
|
+
|
|
|
+ // 回放相关
|
|
|
+ replayMesh: null,
|
|
|
+ replayTimer: null,
|
|
|
+ currentRevisionIndex: 0,
|
|
|
+
|
|
|
+ // 交互状态
|
|
|
+ isDraged: false,
|
|
|
+ isMoved: false,
|
|
|
+ mouseClick: null,
|
|
|
+ currentMarkMesh: null,
|
|
|
+
|
|
|
+ // 定时器
|
|
|
+ viewTimer: null,
|
|
|
+ moveViewTimer: null,
|
|
|
+
|
|
|
+ // 工具实例
|
|
|
+ flowmark: new CreateFlowmark(),
|
|
|
+ createObjectBox: new CreateObjectBox(),
|
|
|
+ createShapMesh: new CreateShapMesh(),
|
|
|
+ createPlaybackeMesh: new CreatePlaybackeMesh()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ ...mapState('vslam', [
|
|
|
+ 'currentView',
|
|
|
+ 'bootModeIsCheck',
|
|
|
+ 'runningState',
|
|
|
+ 'robotPosition',
|
|
|
+ 'robotVisiable',
|
|
|
+ 'uiConfig',
|
|
|
+ 'mqttVisualBoxList',
|
|
|
+ 'replayState'
|
|
|
+ ])
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ /**
|
|
|
+ * 监听视角变化
|
|
|
+ */
|
|
|
+ currentView(newView) {
|
|
|
+ this.handleViewChange(newView)
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 监听引导模式变化
|
|
|
+ */
|
|
|
+ bootModeIsCheck(enabled) {
|
|
|
+ this.handleBootModeChange(enabled)
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 监听机器人位置变化
|
|
|
+ */
|
|
|
+ robotPosition: {
|
|
|
+ handler(newPos) {
|
|
|
+ this.updateRobotPose(newPos)
|
|
|
+ },
|
|
|
+ deep: true
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 监听可视化对象变化
|
|
|
+ */
|
|
|
+ mqttVisualBoxList: {
|
|
|
+ handler(newList) {
|
|
|
+ this.updateVisualObjects(newList)
|
|
|
+ },
|
|
|
+ deep: true
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 监听回放状态
|
|
|
+ */
|
|
|
+ replayState(state) {
|
|
|
+ if (state === 1) {
|
|
|
+ this.startReplay()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ // 等待 Potree 库加载完成
|
|
|
+ this.$nextTick(() => {
|
|
|
+ // 检查 Potree 是否已加载,如果没有,等待加载
|
|
|
+ if (typeof window.Potree !== 'undefined' && window.Potree) {
|
|
|
+ this.initPotreeViewer()
|
|
|
+ } else {
|
|
|
+ console.warn('[VSlamView] Potree 尚未加载,等待中...')
|
|
|
+ // 等待 Potree 加载(最多等待 5 秒)
|
|
|
+ let checkCount = 0
|
|
|
+ const checkInterval = setInterval(() => {
|
|
|
+ checkCount++
|
|
|
+ if (typeof window.Potree !== 'undefined' && window.Potree) {
|
|
|
+ clearInterval(checkInterval)
|
|
|
+ console.log('[VSlamView] Potree 加载完成,开始初始化')
|
|
|
+ this.initPotreeViewer()
|
|
|
+ } else if (checkCount > 50) {
|
|
|
+ // 5 秒后仍未加载
|
|
|
+ clearInterval(checkInterval)
|
|
|
+ console.error('[VSlamView] Potree 加载超时')
|
|
|
+ this.$message.error('Potree 库加载失败,请刷新页面重试')
|
|
|
+ }
|
|
|
+ }, 100)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ this.cleanup()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ ...mapActions('vslam', [
|
|
|
+ 'setCurrentView',
|
|
|
+ 'setBootMode',
|
|
|
+ 'setRunningState',
|
|
|
+ 'setRobotPosition',
|
|
|
+ 'setRobotVisiableState'
|
|
|
+ ]),
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 初始化 Potree Viewer
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ initPotreeViewer() {
|
|
|
+ try {
|
|
|
+ // 检查 Potree 是否已加载
|
|
|
+ if (typeof window.Potree === 'undefined' || !window.Potree) {
|
|
|
+ console.error('[VSlamView] Potree 未加载')
|
|
|
+ this.$message.error('Potree 库未加载,请刷新页面重试')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 输出 Potree 对象的详细信息用于调试
|
|
|
+ console.log('[VSlamView] Potree 对象已加载:', {
|
|
|
+ hasPotree: !!window.Potree,
|
|
|
+ hasViewer: !!window.Potree.Viewer,
|
|
|
+ viewerType: typeof window.Potree.Viewer,
|
|
|
+ potreeKeys: Object.keys(window.Potree).slice(0, 20)
|
|
|
+ })
|
|
|
+
|
|
|
+ const container = document.getElementById('potree_render_area')
|
|
|
+ if (!container) {
|
|
|
+ console.error('[VSlamView] 找不到容器元素')
|
|
|
+ this.$message.error('3D 容器未找到')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查 Viewer 是否为构造函数
|
|
|
+ if (typeof window.Potree.Viewer !== 'function') {
|
|
|
+ console.error('[VSlamView] Potree.Viewer 不是一个构造函数:', typeof window.Potree.Viewer)
|
|
|
+ this.$message.error('Potree.Viewer 初始化失败,请检查库版本')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建 Potree Viewer(使用与 robot_map_editor 相同的方式)
|
|
|
+ console.log('[VSlamView] 开始创建 Potree Viewer...')
|
|
|
+ const Potree = window.Potree
|
|
|
+ this.viewer = new Potree.Viewer(container)
|
|
|
+ window.viewer = this.viewer // 全局引用(兼容性)
|
|
|
+ console.log('[VSlamView] Potree Viewer 创建成功')
|
|
|
+
|
|
|
+ // 设置相机参数
|
|
|
+ this.viewer.setEDLEnabled(false) // 禁用 Eye-Dome Lighting
|
|
|
+ this.viewer.setFOV(60) // 视野角度
|
|
|
+ this.viewer.setPointBudget(1000000) // 点云预算
|
|
|
+ this.viewer.setClipTask(Potree.ClipTask.SHOW_INSIDE)
|
|
|
+
|
|
|
+ // 设置相机初始位置(俯视角度,更适合查看点云)
|
|
|
+ this.viewer.scene.view.position.set(0, -15, 15)
|
|
|
+ this.viewer.scene.view.lookAt(new THREE.Vector3(0, 0, 0))
|
|
|
+
|
|
|
+ // 设置相机控制器参数
|
|
|
+ if (this.viewer.controls) {
|
|
|
+ this.viewer.controls.rotateSpeed = 0.5
|
|
|
+ this.viewer.controls.zoomSpeed = 1.2
|
|
|
+ this.viewer.controls.panSpeed = 0.8
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置纯黑色背景(参考 robot_map_editor)
|
|
|
+ this.viewer.setBackground('black')
|
|
|
+
|
|
|
+ // 添加半球光(参考 robot_map_editor)
|
|
|
+ const hemiLight = new THREE.HemisphereLight(0xffffff, 0x080820, 20)
|
|
|
+ this.viewer.scene.scene.add(hemiLight)
|
|
|
+ console.log('[VSlamView] 添加半球光')
|
|
|
+
|
|
|
+ // 创建极暗的地面网格(几乎不可见,只用于参考)
|
|
|
+ console.log('[VSlamView] 创建暗色地面网格...')
|
|
|
+ this.createDarkGround()
|
|
|
+
|
|
|
+ // 添加坐标轴辅助器(始终显示,帮助定位)
|
|
|
+ const axesHelper = new THREE.AxesHelper(5)
|
|
|
+ this.viewer.scene.scene.add(axesHelper)
|
|
|
+ console.log('[VSlamView] 添加坐标轴辅助器 (红=X, 绿=Y, 蓝=Z)')
|
|
|
+
|
|
|
+ // 添加点云组到场景
|
|
|
+ this.viewer.scene.scene.add(this.pointsGroup)
|
|
|
+ this.viewer.scene.scene.add(this.newPointsGroup)
|
|
|
+ this.viewer.scene.scene.add(this.routeGroup)
|
|
|
+ console.log('[VSlamView] 点云组已添加到场景')
|
|
|
+
|
|
|
+ // 加载机器人模型
|
|
|
+ this.loadRobotModel()
|
|
|
+
|
|
|
+ // 初始化 Workers
|
|
|
+ this.initWorkers()
|
|
|
+
|
|
|
+ // 绑定事件
|
|
|
+ this.bindEvents()
|
|
|
+
|
|
|
+ // 启动统计信息轮询
|
|
|
+ this.startStatisticsPolling()
|
|
|
+
|
|
|
+ // 标记就绪
|
|
|
+ this.viewerReady = true
|
|
|
+
|
|
|
+ // 输出场景统计信息
|
|
|
+ console.log('[VSlamView] Potree Viewer 初始化成功', {
|
|
|
+ sceneChildren: this.viewer.scene.scene.children.length,
|
|
|
+ cameraPosition: this.viewer.scene.view.position,
|
|
|
+ rendererSize: this.viewer.renderer.getSize(new THREE.Vector2())
|
|
|
+ })
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[VSlamView] Potree 初始化失败:', err)
|
|
|
+ this.$message.error('3D 渲染器初始化失败')
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 创建暗色地面(黑色背景下的微弱参考线)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ createDarkGround() {
|
|
|
+ console.log('[VSlamView] 创建暗色地面网格...')
|
|
|
+
|
|
|
+ const size = 200
|
|
|
+ const divisions = 40
|
|
|
+
|
|
|
+ // 创建极暗的网格辅助器(只是隐约可见)
|
|
|
+ const gridHelper = new THREE.GridHelper(size, divisions, 0x111111, 0x0a0a0a)
|
|
|
+ gridHelper.rotation.x = Math.PI / 2 // 旋转到 XY 平面
|
|
|
+ gridHelper.name = 'darkGrid'
|
|
|
+ this.viewer.scene.scene.add(gridHelper)
|
|
|
+
|
|
|
+ console.log('[VSlamView] 暗色地面网格创建成功')
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 创建备用地面(保留以防需要)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ createFallbackGround() {
|
|
|
+ this.createDarkGround()
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 加载机器人模型
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ loadRobotModel() {
|
|
|
+ if (!this.uiConfig || !this.uiConfig.modelName) {
|
|
|
+ console.log('[VSlamView] 未配置机器人模型')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ this.modelOffset = this.uiConfig.offset || [0, 0, 0]
|
|
|
+
|
|
|
+ // 创建机器人模型组
|
|
|
+ this.robotObj = new THREE.Group()
|
|
|
+ this.robotObj.name = 'robotModel'
|
|
|
+
|
|
|
+ // 创建机器人主体(立方体)
|
|
|
+ const bodyGeometry = new THREE.BoxGeometry(0.6, 0.4, 0.3)
|
|
|
+ const bodyMaterial = new THREE.MeshPhongMaterial({
|
|
|
+ color: 0x00ff00,
|
|
|
+ emissive: 0x004400,
|
|
|
+ shininess: 30
|
|
|
+ })
|
|
|
+ const body = new THREE.Mesh(bodyGeometry, bodyMaterial)
|
|
|
+ body.position.z = 0.15
|
|
|
+ this.robotObj.add(body)
|
|
|
+
|
|
|
+ // 创建方向指示器(圆锥)
|
|
|
+ const coneGeometry = new THREE.ConeGeometry(0.15, 0.3, 8)
|
|
|
+ const coneMaterial = new THREE.MeshPhongMaterial({
|
|
|
+ color: 0xffff00,
|
|
|
+ emissive: 0x444400
|
|
|
+ })
|
|
|
+ const cone = new THREE.Mesh(coneGeometry, coneMaterial)
|
|
|
+ cone.rotation.z = -Math.PI / 2
|
|
|
+ cone.position.x = 0.4
|
|
|
+ cone.position.z = 0.15
|
|
|
+ this.robotObj.add(cone)
|
|
|
+
|
|
|
+ // 添加边框线(使其更醒目)
|
|
|
+ const edges = new THREE.EdgesGeometry(bodyGeometry)
|
|
|
+ const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff })
|
|
|
+ const wireframe = new THREE.LineSegments(edges, lineMaterial)
|
|
|
+ wireframe.position.z = 0.15
|
|
|
+ this.robotObj.add(wireframe)
|
|
|
+
|
|
|
+ this.robotObj.visible = false
|
|
|
+ this.viewer.scene.scene.add(this.robotObj)
|
|
|
+
|
|
|
+ console.log('[VSlamView] 机器人模型加载完成 (绿色主体 + 黄色方向锥)')
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 初始化 Web Workers
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ initWorkers() {
|
|
|
+ try {
|
|
|
+ // 统计信息 Worker
|
|
|
+ this.statisticsWorker = new Worker('/workers/StatisticsWorker.js')
|
|
|
+ this.statisticsWorker.onmessage = this.handleStatisticsData
|
|
|
+ this.statisticsWorker.onerror = (err) => {
|
|
|
+ console.error('[VSlamView] StatisticsWorker 错误:', err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 点云数据 Worker
|
|
|
+ this.keyframeWorker = new Worker('/workers/KeyframeWorker.js')
|
|
|
+ this.keyframeWorker.onmessage = this.handleKeyframeData
|
|
|
+ this.keyframeWorker.onerror = (err) => {
|
|
|
+ console.error('[VSlamView] KeyframeWorker 错误:', err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 变换矩阵 Worker
|
|
|
+ this.keyframeTransWorker = new Worker('/workers/KeyframeTransWorker.js')
|
|
|
+ this.keyframeTransWorker.onmessage = this.handleTransformData
|
|
|
+ this.keyframeTransWorker.onerror = (err) => {
|
|
|
+ console.error('[VSlamView] KeyframeTransWorker 错误:', err)
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[VSlamView] Web Workers 初始化完成')
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[VSlamView] Web Workers 初始化失败:', err)
|
|
|
+ this.$message.warning('数据加载功能可能受限')
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 启动统计信息轮询
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ startStatisticsPolling() {
|
|
|
+ if (!this.statisticsWorker) {
|
|
|
+ console.warn('[VSlamView] StatisticsWorker 未初始化,跳过轮询')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const url = urlVSlamStatistics(this.mapName)
|
|
|
+ console.log('[VSlamView] 启动统计信息轮询:', url)
|
|
|
+ this.statisticsWorker.postMessage({
|
|
|
+ action: 'startPolling',
|
|
|
+ url: url,
|
|
|
+ interval: 1000 // 1 秒轮询一次
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 处理统计信息数据
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ handleStatisticsData(event) {
|
|
|
+ const result = event.data
|
|
|
+
|
|
|
+ // 处理新的返回格式
|
|
|
+ if (result.type === 'error') {
|
|
|
+ console.error('[VSlamView] 统计信息获取失败:', result.error)
|
|
|
+ this.$message.error(`统计信息获取失败: ${result.error}`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = result.data || result // 兼容旧格式
|
|
|
+
|
|
|
+ // 更新运行状态
|
|
|
+ this.setRunningState(data.running || false)
|
|
|
+
|
|
|
+ // 获取关键帧数量
|
|
|
+ const keyframes = data.keyframes || 0
|
|
|
+ const closures = data.closures || 0
|
|
|
+
|
|
|
+ console.log('[VSlamView] 统计信息:', {
|
|
|
+ running: data.running,
|
|
|
+ keyframes: keyframes,
|
|
|
+ closures: closures,
|
|
|
+ currentFrames: this.cloudArry.length
|
|
|
+ })
|
|
|
+
|
|
|
+ // 如果有新帧,获取点云和变换矩阵
|
|
|
+ if (keyframes > this.cloudArry.length) {
|
|
|
+ const newFramesCount = keyframes - this.cloudArry.length
|
|
|
+ console.log(`[VSlamView] ✨ 发现 ${newFramesCount} 个新帧: ${this.cloudArry.length} -> ${keyframes}`)
|
|
|
+
|
|
|
+ for (let i = this.cloudArry.length; i < keyframes; i++) {
|
|
|
+ this.fetchKeyframeData(i)
|
|
|
+ }
|
|
|
+ } else if (keyframes === this.cloudArry.length && keyframes > 0) {
|
|
|
+ console.log(`[VSlamView] 点云已是最新 (${keyframes} 帧)`)
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 获取关键帧数据(点云 + 变换矩阵)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ fetchKeyframeData(index) {
|
|
|
+ console.log(`[VSlamView] 🚀 开始获取关键帧 ${index} 数据`)
|
|
|
+
|
|
|
+ // 获取点云
|
|
|
+ const cloudUrl = urlKeyframePointcloud(this.mapName, index)
|
|
|
+ this.keyframeWorker.postMessage({ url: cloudUrl, index })
|
|
|
+
|
|
|
+ // 获取变换矩阵
|
|
|
+ const transUrl = urlKeyframeTrans(this.mapName, index)
|
|
|
+ this.keyframeTransWorker.postMessage({ url: transUrl, index })
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 处理关键帧点云数据
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ handleKeyframeData(event) {
|
|
|
+ try {
|
|
|
+ const result = event.data
|
|
|
+
|
|
|
+ // 处理新的返回格式
|
|
|
+ if (result.type === 'error') {
|
|
|
+ console.error('[VSlamView] 点云数据获取失败:', result.error)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const uint8Array = result.data || result // 兼容旧格式
|
|
|
+ const index = result.index
|
|
|
+
|
|
|
+ // 解析 Protobuf 数据
|
|
|
+ const parsed = parsePointcloudData(uint8Array)
|
|
|
+
|
|
|
+ console.log(`[VSlamView] 📦 点云 ${index} 解析成功:`, {
|
|
|
+ points: parsed.pointsList?.length || 0,
|
|
|
+ hasTransform: !!this.gTransArry[index]
|
|
|
+ })
|
|
|
+
|
|
|
+ // 存储点云数据
|
|
|
+ this.cloudArry[index] = parsed
|
|
|
+
|
|
|
+ // 如果已有对应的变换矩阵,创建点云
|
|
|
+ if (this.gTransArry[index]) {
|
|
|
+ console.log(`[VSlamView] ✅ 点云 ${index} 数据完整,开始创建`)
|
|
|
+ this.createPointCloud(index)
|
|
|
+ } else {
|
|
|
+ console.log(`[VSlamView] ⏳ 点云 ${index} 等待变换矩阵`)
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[VSlamView] 解析点云数据失败:', err)
|
|
|
+ this.$message.error(`点云解析失败: ${err.message}`)
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 处理变换矩阵数据
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ handleTransformData(event) {
|
|
|
+ try {
|
|
|
+ const result = event.data
|
|
|
+
|
|
|
+ // 处理新的返回格式
|
|
|
+ if (result.type === 'error') {
|
|
|
+ console.error('[VSlamView] 变换矩阵获取失败:', result.error)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const uint8Array = result.data || result // 兼容旧格式
|
|
|
+ const index = result.index
|
|
|
+
|
|
|
+ // 解析 Protobuf 数据
|
|
|
+ const parsed = parseTransformData(uint8Array)
|
|
|
+
|
|
|
+ console.log(`[VSlamView] 🔄 变换矩阵 ${index} 解析成功:`, {
|
|
|
+ position: `(${parsed.tx?.toFixed(2)}, ${parsed.ty?.toFixed(2)}, ${parsed.tz?.toFixed(2)})`,
|
|
|
+ hasPointCloud: !!this.cloudArry[index]
|
|
|
+ })
|
|
|
+
|
|
|
+ // 存储变换矩阵
|
|
|
+ this.gTransArry[index] = parsed
|
|
|
+
|
|
|
+ // 如果已有对应的点云数据,创建点云
|
|
|
+ if (this.cloudArry[index]) {
|
|
|
+ console.log(`[VSlamView] ✅ 变换矩阵 ${index} 数据完整,开始创建`)
|
|
|
+ this.createPointCloud(index)
|
|
|
+ } else {
|
|
|
+ console.log(`[VSlamView] ⏳ 变换矩阵 ${index} 等待点云数据`)
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[VSlamView] 解析变换矩阵失败:', err)
|
|
|
+ this.$message.error(`变换矩阵解析失败: ${err.message}`)
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 创建点云对象(改进版)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ createPointCloud(index) {
|
|
|
+ const cloud = this.cloudArry[index]
|
|
|
+ const trans = this.gTransArry[index]
|
|
|
+
|
|
|
+ if (!cloud || !trans) {
|
|
|
+ console.warn(`[VSlamView] 点云 ${index} 数据不完整`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 使用 Utils 生成点云粒子
|
|
|
+ const { object, box } = Utils.genParticles(
|
|
|
+ cloud.pointsList,
|
|
|
+ trans,
|
|
|
+ null
|
|
|
+ )
|
|
|
+
|
|
|
+ object.transIndex = index
|
|
|
+ object.name = `pointcloud_${index}`
|
|
|
+ this.pointsGroup.add(object)
|
|
|
+
|
|
|
+ // 更新地面网格(动态扩展)
|
|
|
+ if (this.groundMeshFunc && box) {
|
|
|
+ this.groundMeshFunc.updateGround(
|
|
|
+ Math.abs(box.min.x),
|
|
|
+ box.max.x,
|
|
|
+ box.max.y,
|
|
|
+ Math.abs(box.min.y)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`[VSlamView] 点云 ${index} 创建成功 (${cloud.pointsList.length} 点)`)
|
|
|
+
|
|
|
+ // 首次点云创建时触发视锥剔除
|
|
|
+ if (this.pointsGroup.children.length === 1) {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.performFrustumCulling()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error(`[VSlamView] 点云 ${index} 创建失败:`, err)
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 批量创建点云(性能优化)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ createPointCloudsBatch(indices) {
|
|
|
+ console.log(`[VSlamView] 批量创建点云: ${indices.length} 帧`)
|
|
|
+
|
|
|
+ let successCount = 0
|
|
|
+ indices.forEach((index) => {
|
|
|
+ try {
|
|
|
+ this.createPointCloud(index)
|
|
|
+ successCount++
|
|
|
+ } catch (err) {
|
|
|
+ console.error(`[VSlamView] 批量创建失败 ${index}:`, err)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ console.log(`[VSlamView] 批量创建完成: ${successCount}/${indices.length} 帧`)
|
|
|
+
|
|
|
+ // 批量创建后触发一次视锥剔除
|
|
|
+ if (successCount > 0) {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.performFrustumCulling()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 绑定事件
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ bindEvents() {
|
|
|
+ const container = document.getElementById('potree_render_area')
|
|
|
+
|
|
|
+ // 鼠标按下
|
|
|
+ container.addEventListener('mousedown', this.onMouseDown)
|
|
|
+
|
|
|
+ // 鼠标抬起
|
|
|
+ container.addEventListener('mouseup', this.onMouseUp)
|
|
|
+
|
|
|
+ // 鼠标移动
|
|
|
+ container.addEventListener('mousemove', this.onMouseMove)
|
|
|
+
|
|
|
+ // 相机移动(视锥剔除)
|
|
|
+ this.viewer.addEventListener('camera_changed', this.onCameraChanged)
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 鼠标按下事件
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ onMouseDown(event) {
|
|
|
+ this.isDraged = false
|
|
|
+ this.mouseClick = { x: event.clientX, y: event.clientY }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 鼠标移动事件
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ onMouseMove(event) {
|
|
|
+ if (this.mouseClick) {
|
|
|
+ const dx = Math.abs(event.clientX - this.mouseClick.x)
|
|
|
+ const dy = Math.abs(event.clientY - this.mouseClick.y)
|
|
|
+ if (dx > 5 || dy > 5) {
|
|
|
+ this.isDraged = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 鼠标抬起事件(引导模式点击)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ onMouseUp(event) {
|
|
|
+ if (this.isDraged || !this.bootModeIsCheck) {
|
|
|
+ this.mouseClick = null
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 射线检测
|
|
|
+ const rect = event.target.getBoundingClientRect()
|
|
|
+ const x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
|
+ const y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
|
+
|
|
|
+ const raycaster = new THREE.Raycaster()
|
|
|
+ raycaster.setFromCamera(new THREE.Vector2(x, y), this.viewer.scene.getActiveCamera())
|
|
|
+
|
|
|
+ // 检测与地面的交点
|
|
|
+ const intersects = raycaster.intersectObject(this.currentPlane || this.viewer.scene.scene, true)
|
|
|
+
|
|
|
+ if (intersects.length > 0) {
|
|
|
+ const point = intersects[0].point
|
|
|
+ this.handleGroundClick(point)
|
|
|
+ }
|
|
|
+
|
|
|
+ this.mouseClick = null
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 处理地面点击(引导模式)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ handleGroundClick(point) {
|
|
|
+ console.log('[VSlamView] 地面点击:', point)
|
|
|
+
|
|
|
+ // 创建波纹效果
|
|
|
+ if (this.currentMarkMesh) {
|
|
|
+ this.viewer.scene.scene.remove(this.currentMarkMesh)
|
|
|
+ }
|
|
|
+
|
|
|
+ const distance = this.viewer.scene.view.position.distanceTo(point)
|
|
|
+ this.currentMarkMesh = this.flowmark.create(distance * 0.01, point)
|
|
|
+ this.viewer.scene.scene.add(this.currentMarkMesh)
|
|
|
+
|
|
|
+ // TODO: 通过 MQTT 发送目标点
|
|
|
+ this.$emit('ground-click', point)
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 相机变化事件(视锥剔除)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ onCameraChanged() {
|
|
|
+ if (this.moveViewTimer) {
|
|
|
+ clearTimeout(this.moveViewTimer)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 防抖处理:相机停止移动 300ms 后执行视锥剔除
|
|
|
+ this.moveViewTimer = setTimeout(() => {
|
|
|
+ this.performFrustumCulling()
|
|
|
+ }, 300)
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 视锥剔除(性能优化核心)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ performFrustumCulling() {
|
|
|
+ if (!this.viewer || !this.viewer.scene) return
|
|
|
+
|
|
|
+ const camera = this.viewer.scene.getActiveCamera()
|
|
|
+ if (!camera) return
|
|
|
+
|
|
|
+ // 构建视锥体
|
|
|
+ const frustum = new THREE.Frustum()
|
|
|
+ const matrix = new THREE.Matrix4().multiplyMatrices(
|
|
|
+ camera.projectionMatrix,
|
|
|
+ camera.matrixWorldInverse
|
|
|
+ )
|
|
|
+ frustum.setFromProjectionMatrix(matrix)
|
|
|
+
|
|
|
+ // 找出视锥内的点云索引
|
|
|
+ const intersectingIndexs = []
|
|
|
+ for (let i = 0; i < this.gTransArry.length; i++) {
|
|
|
+ const trans = this.gTransArry[i]
|
|
|
+ if (!trans) continue
|
|
|
+
|
|
|
+ const point = new THREE.Vector3(trans.tx, trans.ty, trans.tz)
|
|
|
+ if (frustum.containsPoint(point)) {
|
|
|
+ intersectingIndexs.push(i)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`[VSlamView] 视锥剔除: ${intersectingIndexs.length}/${this.gTransArry.length} 帧可见`)
|
|
|
+
|
|
|
+ // 更新显示的点云(增量更新)
|
|
|
+ createIntersectPointsMesh(
|
|
|
+ this.newPointsGroup,
|
|
|
+ intersectingIndexs,
|
|
|
+ this.cloudArry,
|
|
|
+ this.gTransArry
|
|
|
+ )
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 视角切换(改进版)
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ handleViewChange(viewId) {
|
|
|
+ if (!this.viewer || !this.viewer.scene) return
|
|
|
+
|
|
|
+ const robotPos = this.robotPosition || { x: 0, y: 0, z: 0 }
|
|
|
+ const robotVec = new THREE.Vector3(robotPos.x, robotPos.y, robotPos.z)
|
|
|
+
|
|
|
+ let cameraPosition, cameraTarget
|
|
|
+
|
|
|
+ switch (viewId) {
|
|
|
+ case 1: // 俯视图
|
|
|
+ const currentZ = this.viewer.scene.view.position.z
|
|
|
+ const viewHeight = Math.max(currentZ, 20) // 至少20米高
|
|
|
+ cameraPosition = new THREE.Vector3(robotVec.x, robotVec.y, viewHeight)
|
|
|
+ cameraTarget = robotVec.clone()
|
|
|
+ this.setCamera(cameraPosition, cameraTarget)
|
|
|
+ console.log('[VSlamView] 切换到俯视图')
|
|
|
+ break
|
|
|
+
|
|
|
+ case 2: // 第三人称
|
|
|
+ // 相对机器人后方 4.5米,高 2米
|
|
|
+ cameraPosition = new THREE.Vector3(-4.5, 0, 2)
|
|
|
+ // 如果机器人有朝向,考虑旋转
|
|
|
+ if (this.robotObj && this.robotObj.rotation) {
|
|
|
+ const euler = new THREE.Euler(0, 0, this.robotObj.rotation.z)
|
|
|
+ cameraPosition.applyEuler(euler)
|
|
|
+ }
|
|
|
+ cameraPosition.add(robotVec)
|
|
|
+ cameraTarget = robotVec.clone().add(new THREE.Vector3(0, 0, 1.5))
|
|
|
+ this.setCamera(cameraPosition, cameraTarget)
|
|
|
+ console.log('[VSlamView] 切换到第三人称视角')
|
|
|
+ break
|
|
|
+
|
|
|
+ case 3: // 第一人称
|
|
|
+ // 机器人视角(高度 1米)
|
|
|
+ cameraPosition = robotVec.clone().add(new THREE.Vector3(0, 0, 1))
|
|
|
+ cameraTarget = robotVec.clone().add(new THREE.Vector3(1, 0, 1))
|
|
|
+ // 如果机器人有朝向,旋转视线方向
|
|
|
+ if (this.robotObj && this.robotObj.rotation) {
|
|
|
+ const direction = new THREE.Vector3(1, 0, 0)
|
|
|
+ const euler = new THREE.Euler(0, 0, this.robotObj.rotation.z)
|
|
|
+ direction.applyEuler(euler)
|
|
|
+ cameraTarget = cameraPosition.clone().add(direction)
|
|
|
+ }
|
|
|
+ this.setCamera(cameraPosition, cameraTarget)
|
|
|
+ console.log('[VSlamView] 切换到第一人称视角')
|
|
|
+ break
|
|
|
+
|
|
|
+ case 4: // 当前视角(跟随)
|
|
|
+ // 保持当前相机和目标的相对位置,跟随机器人移动
|
|
|
+ if (this.prevRobotPosition) {
|
|
|
+ const offset = new THREE.Vector3().subVectors(
|
|
|
+ this.viewer.scene.view.position,
|
|
|
+ this.prevRobotPosition
|
|
|
+ )
|
|
|
+ cameraPosition = robotVec.clone().add(offset)
|
|
|
+ cameraTarget = robotVec.clone()
|
|
|
+ this.setCamera(cameraPosition, cameraTarget)
|
|
|
+ }
|
|
|
+ this.prevRobotPosition = robotVec.clone()
|
|
|
+ console.log('[VSlamView] 当前视角跟随模式')
|
|
|
+ break
|
|
|
+
|
|
|
+ case 5: // 自由视角
|
|
|
+ // 不改变相机位置,用户可自由控制
|
|
|
+ console.log('[VSlamView] 自由视角模式')
|
|
|
+ break
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 设置相机位置和目标
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ setCamera(position, target) {
|
|
|
+ if (!this.viewer || !this.viewer.scene || !this.viewer.scene.view) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Potree 的 setView 方法
|
|
|
+ if (typeof this.viewer.scene.view.setView === 'function') {
|
|
|
+ this.viewer.scene.view.setView(
|
|
|
+ [position.x, position.y, position.z],
|
|
|
+ [target.x, target.y, target.z]
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ // 备用方法
|
|
|
+ this.viewer.scene.view.position.copy(position)
|
|
|
+ this.viewer.scene.view.lookAt(target)
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('[VSlamView] 设置相机失败:', err)
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 引导模式切换
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ handleBootModeChange(enabled) {
|
|
|
+ if (enabled) {
|
|
|
+ this.$message.success('引导模式已开启,点击地面发送目标点')
|
|
|
+ } else {
|
|
|
+ this.$message.info('引导模式已关闭')
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 更新机器人位姿
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ updateRobotPose(position, yaw) {
|
|
|
+ if (!this.robotObj || !position) return
|
|
|
+
|
|
|
+ // 更新位置
|
|
|
+ this.robotObj.position.set(
|
|
|
+ position.x + this.modelOffset[0],
|
|
|
+ position.y + this.modelOffset[1],
|
|
|
+ position.z + this.modelOffset[2]
|
|
|
+ )
|
|
|
+
|
|
|
+ // 更新朝向(如果提供了 yaw 角度)
|
|
|
+ if (yaw !== undefined) {
|
|
|
+ this.robotObj.rotation.z = yaw
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置可见
|
|
|
+ if (!this.robotObj.visible) {
|
|
|
+ this.robotObj.visible = true
|
|
|
+ this.setRobotVisiableState(true)
|
|
|
+ console.log('[VSlamView] 机器人模型已显示')
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果是跟随视角,更新相机
|
|
|
+ if (this.currentView === 4) {
|
|
|
+ this.handleViewChange(4)
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 更新可视化对象
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ updateVisualObjects(objectList) {
|
|
|
+ // 清除旧对象
|
|
|
+ this.visualObjectMeshs.forEach(mesh => {
|
|
|
+ this.viewer.scene.scene.remove(mesh)
|
|
|
+ })
|
|
|
+ this.visualObjectMeshs = []
|
|
|
+
|
|
|
+ // 创建新对象
|
|
|
+ if (objectList && objectList.length > 0) {
|
|
|
+ objectList.forEach(obj => {
|
|
|
+ const boxMesh = this.createObjectBox.create(obj)
|
|
|
+ if (boxMesh) {
|
|
|
+ this.viewer.scene.scene.add(boxMesh)
|
|
|
+ this.visualObjectMeshs.push(boxMesh)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 开始回放
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ startReplay() {
|
|
|
+ console.log('[VSlamView] 开始回放建图过程')
|
|
|
+
|
|
|
+ if (!this.gTransArry || this.gTransArry.length === 0) {
|
|
|
+ this.$message.warning('没有可回放的数据')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ this.currentRevisionIndex = 0
|
|
|
+ this.replayMesh = new CreatePlaybackeMesh()
|
|
|
+
|
|
|
+ this.replayTimer = setInterval(() => {
|
|
|
+ if (this.currentRevisionIndex >= this.gTransArry.length) {
|
|
|
+ clearInterval(this.replayTimer)
|
|
|
+ this.$message.success('回放完成')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const trans = this.gTransArry[this.currentRevisionIndex]
|
|
|
+ if (trans) {
|
|
|
+ // TODO: 实现回放逻辑
|
|
|
+ const points = [[trans.tx, trans.ty, trans.tz]]
|
|
|
+ const mesh = this.replayMesh.create(points)
|
|
|
+ if (mesh) {
|
|
|
+ this.viewer.scene.scene.add(mesh)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.currentRevisionIndex++
|
|
|
+ }, 100)
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * =======================================
|
|
|
+ * 清理资源
|
|
|
+ * =======================================
|
|
|
+ */
|
|
|
+ cleanup() {
|
|
|
+ console.log('[VSlamView] 开始清理资源...')
|
|
|
+
|
|
|
+ // 停止 Workers
|
|
|
+ if (this.statisticsWorker) {
|
|
|
+ this.statisticsWorker.postMessage({ action: 'stopPolling' })
|
|
|
+ this.statisticsWorker.terminate()
|
|
|
+ }
|
|
|
+ if (this.keyframeWorker) {
|
|
|
+ this.keyframeWorker.terminate()
|
|
|
+ }
|
|
|
+ if (this.keyframeTransWorker) {
|
|
|
+ this.keyframeTransWorker.terminate()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 停止定时器
|
|
|
+ if (this.viewTimer) clearTimeout(this.viewTimer)
|
|
|
+ if (this.replayTimer) clearInterval(this.replayTimer)
|
|
|
+ if (this.moveViewTimer) clearTimeout(this.moveViewTimer)
|
|
|
+
|
|
|
+ // 重置视锥剔除状态
|
|
|
+ resetIntersectPointsState()
|
|
|
+
|
|
|
+ // 释放 Three.js 资源
|
|
|
+ this.pointsGroup.children.forEach(child => {
|
|
|
+ if (child.geometry) child.geometry.dispose()
|
|
|
+ if (child.material) child.material.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ // 销毁 Potree Viewer
|
|
|
+ if (this.viewer) {
|
|
|
+ this.viewer = null
|
|
|
+ window.viewer = null
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[VSlamView] 资源清理完成')
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.vslam-view-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #000;
|
|
|
+}
|
|
|
+
|
|
|
+.potree-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|