Potree 002 Desktop开发环境搭建
我们使用Visual Studio 2022开发,把下载好后的PotreeDesktop源码添加到Visual Studio中。
打开Visual Studio 2022,新建Asp.Net Core空项目,如下图所示。
点击下一步按钮,设置项目的名称、存储路径以及解决方案名称等。如下图所示。
点击下一步按钮,最后点击创建按钮,完成创建工作。创建后的根目录如下图所示。
打开WOBM.Potree.Web目录,里面的内容如下图所示。
创建的工程在Visual Studio中的工程树如下图所示。
接下来,我们把PotreeDesktop下的源码,拷贝到WOBM.Potree.Web目录下,拷贝后,文件夹组织如下图所示。
从Visual Studio的工程树上,通过添加、删除和项目中排除等方法,最终工程树的组织如下图所示。
此时,双击PotreeDesktop.bat,如果系统能够成功启动,那么开发环境就算搭建成功了,接下来就可以在Visual Studio写我们自定义的html和js代码了。
系统是从PotreeDesktop.bat开始启动的,里面的内容如下所示。
start ./node_modules/electron/dist/electron.exe ./main
系统从electron.exe启动,后面跟了一个参数,参数启动了main.js文件。在main.js中有下面一句代码。
mainWindow.loadFile(path.join(__dirname, 'index.html'));
该代码的意思,是在mainWindow中加载index.html文件,所以index.html页面是我们展示的主页面。打开index.html页面,我们可以看到,该页面就是我们平常开发的Web页面,把这个页面单独放到Web服务器,然后通过浏览器访问也是可以的。
在index.html页面中,我们看到了熟悉的代码,例如对其他js库的引用,html元素的定义和一些js代码。js代码中,定义了Potree.Viewer,也就是显示点云的主UI。
整个执行流程如下,通过PotreeDesktop.bat启动electron.exe,electron.exe加载解析main.js文件,在main.js文件中设置加载index.html页面,至此完成了我们开发的主页面的加载。
const electron = require('electron') const app = electron.app const BrowserWindow = electron.BrowserWindow const Menu = electron.Menu; const MenuItem = electron.MenuItem; const remote = electron.remote; const path = require('path') const url = require('url') let mainWindow
通过这段代码,获取了对electron的引用,app为系统的主App,BrowserWindow是主对话框,Menu是主对话框中的菜单。接下来对app进行操作。
app.on('ready', createWindow) app.on('window-all-closed', function () { if(process.platform !== 'darwin') { app.quit() } }) app.on('activate', function () { if(mainWindow === null) { createWindow() } })
当app准备好之后,系统会调用createWindow函数。当所有的窗口关闭后,调用app的quit函数。当app被激活的时候,如果当前的mainWindow为null的话,调用createWindow函数。
下面我们顺藤摸瓜,看下定义在main.js中的createWindow函数。代码如下。
function createWindow () { mainWindow= new BrowserWindow({ width:1600, height:1200, webPreferences:{ nodeIntegration:true, backgroundThrottling:false, } }) mainWindow.loadFile(path.join(__dirname,'index.html')); lettemplate = [ { label:"Window", submenu:[ {label:"Reload", click() {mainWindow.webContents.reloadIgnoringCache() }}, {label:"Toggle Developer Tools", click() {mainWindow.webContents.toggleDevTools() }}, ] } ]; letmenu = Menu.buildFromTemplate(template); mainWindow.setMenu(menu); { const{ ipcMain } = require('electron'); ipcMain.on('asynchronous-message',(event, arg) => { console.log(arg)// prints "ping" event.reply('asynchronous-reply','pong') }) ipcMain.on('synchronous-message',(event, arg) => { console.log(arg)// prints "ping" event.returnValue= 'pong' }) } mainWindow.on('closed',function () { mainWindow= null }) }
在该代码中,系统初始化了一个BrowserWindow对象,也就是初始化一个主对话框,设置了宽度为1600,宽度为1200。调用mainWindow.loadFile函数,加载Index.html页面。下面的代码,就是组织主菜单上的菜单,我们开发的时候,一般不用electron上定义的菜单,而直接在Index.html中的菜单。所以实际开发的时候,会把这段代码去掉。
最后是监听mainWindow的closed事件,当窗体关闭后,把主变量mainWindow设置为null。
在该文件的Head部分,系统引用了一些css,代码如下所示。
<link rel="stylesheet" type="text/css" href="./libs/potree/potree.css"> <link rel="stylesheet" type="text/css" href="./libs/jquery-ui/jquery-ui.min.css"> <link rel="stylesheet" type="text/css" href="./libs/openlayers3/ol.css"> <link rel="stylesheet" type="text/css" href="./libs/spectrum/spectrum.css"> <link rel="stylesheet" type="text/css" href="./libs/jstree/themes/mixed/style.css"> <link rel="stylesheet" type="text/css" href="./src/desktop.css">
<script> if (typeof module === 'object') { window.module = module; module = undefined; } </script> <script src="./libs/jquery/jquery-3.1.1.min.js"></script> <script src="./libs/spectrum/spectrum.js"></script> <script src="./libs/jquery-ui/jquery-ui.min.js"></script> <script src="./libs/other/BinaryHeap.js"></script> <script src="./libs/tween/tween.min.js"></script> <script src="./libs/d3/d3.js"></script> <script src="./libs/proj4/proj4.js"></script> <script src="./libs/openlayers3/ol.js"></script> <script src="./libs/i18next/i18next.js"></script> <script src="./libs/jstree/jstree.js"></script> <script src="./libs/potree/potree.js"></script> <script src="./libs/plasio/js/laslaz.js"></script>
<div class="potree_container" style="position: absolute; width: 100%; height: 100%; left: 0px; top: 0px; "> <div id="potree_render_area"></div> <div id="potree_sidebar_container"></div> </div>
其中potree_render_area用来放主渲染UI对象Viewer,potree_sidebar_container用来放左侧的功能面板。
let elRenderArea = document.getElementById("potree_render_area"); let viewerArgs = { noDragAndDrop: true }; window.viewer = new Potree.Viewer(elRenderArea, viewerArgs); viewer.setEDLEnabled(true); viewer.setFOV(60); viewer.setPointBudget(3 * 1000 * 1000); viewer.setMinNodeSize(0); viewer.loadSettingsFromURL(); viewer.setDescription(""); viewer.loadGUI(() => { viewer.setLanguage('en'); $("#menu_appearance").next().show(); $("#menu_tools").next().show(); $("#menu_scene").next().show(); $("#menu_filters").next().show(); viewer.toggleSidebar(); });
系统启动后,整个系统的主界面就出来了,如下图所示。
左侧为功能面板区,是potree_sidebar_container对应的区域,右侧为点云主显示区,是potree_render_area对应的区域。系统启动后,我们会发现,可以通过把las文件拖到界面上的方式,处理和加载点云数据。这个功能是在desktop.js中实现的。
在index.html中定义了和拖动las文件相关的代码,如下所示。
import { loadDroppedPointcloud, createPlaceholder, convert_17, convert_20, doConversion, dragEnter, dragOver, dragLeave, dropHandler, } from "./src/desktop.js"; const shell = require('electron').shell; $(document).on('click', 'a[href^="http"]', function (event) { event.preventDefault(); shell.openExternal(this.href); }); let elBody = document.body; elBody.addEventListener("dragenter", dragEnter, false); elBody.addEventListener("dragover", dragOver, false); elBody.addEventListener("dragleave", dragLeave, false); elBody.addEventListener("drop", dropHandler, false);
从desktop.js文件引用 dragEnter,dragOver, dragLeave, dropHandler等模块。然后在document.body上注册事件,在dragenter的时候,调用desktop.js定义的dragEnter模块,dragover的时候,调用dragOver函数,dragleave的时候,调用dragleave函数,触发drop事件的时候,调用dropHandler函数。
下面我们再看下desktop.js文件中,关于这几个函数和模块的定义。
export function dragEnter(e) { e.dataTransfer.dropEffect = 'copy'; e.preventDefault(); e.stopPropagation(); console.log("enter"); showDropzones(); return false; } export function dragOver(e){ e.preventDefault(); e.stopPropagation(); showDropzones(); return false; } export function dragLeave(e){ e.preventDefault(); e.stopPropagation(); hideDropzones(); return false; }
这三个函数定义的比较简单,当拖到主区域的时候,显示拖拽区域,当移动到主区域的时候,也显示拖拽区域,当离开的时候,则隐藏该区域。显示拖拽区域的效果如下图所示。
当鼠标放下之后,系统会调用dropHandler函数,该函数的定义如下。
export async function dropHandler(event) { event.preventDefault(); event.stopPropagation(); hideDropzones(); let u = event.clientX / document.body.clientWidth; console.log(u); const cloudJsFiles = []; const lasLazFiles = []; let suggestedDirectory = null; let suggestedName = null; for (let i = 0; i < event.dataTransfer.items.length; i++) { let item = event.dataTransfer.items[i]; if (item.kind !== "file") { continue; } let file = item.getAsFile(); let path = file.path; const fs = require("fs"); const fsp = fs.promises; const np = require('path'); const whitelist = [".las", ".laz"]; let isFile = fs.lstatSync(path).isFile(); if (isFile && path.indexOf("cloud.js") >= 0) { cloudJsFiles.push(file.path); } else if (isFile && path.indexOf("metadata.json") >= 0) { cloudJsFiles.push(file.path); } else if (isFile) { const extension = np.extname(path).toLowerCase(); if (whitelist.includes(extension)) { lasLazFiles.push(file.path); if (suggestedDirectory == null) { suggestedDirectory = np.normalize(`${path}/..`); suggestedName = np.basename(path, np.extname(path)) + "_converted"; } } } else if (fs.lstatSync(path).isDirectory()) { console.log("start readdir!"); const files = await fsp.readdir(path); console.log("readdir done!"); for (const file of files) { const extension = np.extname(file).toLowerCase(); if (whitelist.includes(extension)) { lasLazFiles.push(`${path}/${file}`); if (suggestedDirectory == null) { suggestedDirectory = np.normalize(`${path}/..`); suggestedName = np.basename(path, np.extname(path)) + "_converted"; } } else if (file.toLowerCase().endsWith("cloud.js")) { cloudJsFiles.push(`${path}/${file}`); } else if (file.toLowerCase().endsWith("metadata.json")) { cloudJsFiles.push(`${path}/${file}`); } }; } } if (lasLazFiles.length > 0) { doConversion(lasLazFiles, suggestedDirectory, suggestedName); } for (const cloudjs of cloudJsFiles) { loadDroppedPointcloud(cloudjs); } return false; };
该代码中,判断拖入系统的内容,可能是文件或者目录,系统需要取出拖入内容包含的cloud.js文件、metadata.json文件或者.las和.laz文件。如果是cloud.js和metadata.json文件,则不需要进行转换,直接调用loadDroppedPointcloud函数加载即可。如果文件是.las和.laz文件,则调用doConversion函数。
export function loadDroppedPointcloud(cloudjsPath) { const folderName = cloudjsPath.replace(/\\/g, "/").split("/").reverse()[1]; Potree.loadPointCloud(cloudjsPath).then(e => { let pointcloud = e.pointcloud; let material = pointcloud.material; pointcloud.name = folderName; viewer.scene.addPointCloud(pointcloud); let hasRGBA = pointcloud.getAttributes().attributes.find(a => a.name === "rgba") !== undefined if (hasRGBA) { pointcloud.material.activeAttributeName = "rgba"; } else { pointcloud.material.activeAttributeName = "color"; } material.size = 1; material.pointSizeType = Potree.PointSizeType.ADAPTIVE; viewer.zoomTo(e.pointcloud); }); };
获取点云文件路径后,调用Potree.loadPointCloud函数,加载点云数据。该函数执行后,获取pointcloud,系统调用viewer.scene.addPointCloud函数,把打开的点云数据加载到场景中。后面的代码是判断该点云是否包含RGBA属性,如果包含,则使用rgba方式显示点云,如果不包含,则使用单颜色模式显示。
代码最后,设置点显示大小,大小模式并把场景缩放至该点云数据。
当文件是.las和.laz时候,调用doConversion函数,该函数会判断当前用户选择的是使用1.7版本的转换程序,还是使用2.0版本的转换程序,选择不同,调用desktop.js中定义的不同转换函数,以2.0为例,其定义代码如下所示。
export function convert_20(inputPaths, chosenPath, pointcloudName) { viewer.postMessage(message, { duration: 15000 }); const { spawn, fork, execFile } = require('child_process'); let exe = './libs/PotreeConverter2/PotreeConverter.exe'; let parameters = [ ...inputPaths, "-o", chosenPath ]; const converter = spawn(exe, parameters); let placeholder = null; let outputBuffer = ""; converter.stdout.on('data', (data) => { const string = new TextDecoder("utf-8").decode(data); console.log("stdout", string); }); converter.stderr.on('data', (data) => { console.log("=="); console.error(`stderr: ${data}`); }); converter.on('exit', (code) => { console.log(`child process exited with code ${code}`); const cloudJS = `${chosenPath}/metadata.json`; console.log("now loading point cloud: " + cloudJS); let message = `conversion finished, now loading ${cloudJS}`; viewer.postMessage(message, { duration: 15000 }); Potree.loadPointCloud(cloudJS).then(e => { let pointcloud = e.pointcloud; let material = pointcloud.material; pointcloud.name = pointcloudName; let hasRGBA = pointcloud.getAttributes().attributes.find(a => a.name === "rgba") !== undefined if (hasRGBA) { pointcloud.material.activeAttributeName = "rgba"; } else { pointcloud.material.activeAttributeName = "color"; } material.size = 1; material.pointSizeType = Potree.PointSizeType.ADAPTIVE; viewer.scene.addPointCloud(pointcloud); viewer.zoomTo(e.pointcloud); }); }); }
系统通过spawn调用PotreeConverter.exe,并传入组织好的参数,参数包括输入文件和输出文件路径等。在spawn执行的过程中,可以通过converter.stdout.on('data', (data)捕捉进度信息,通过converter.stderr.on('data', (data)捕捉错误信息,通过converter.on('exit', (code)捕捉执行结束事件。当执行结束后,转换后的结果会包含metadata.json文件,然后调用Potree.loadPointCloud函数加载该文件即可,加载的过程和上面提到的直接加载点云数据一致。