Potree 002 Desktop开发环境搭建

时间:2023-01-02 13:07:25

1、工程创建

我们使用Visual Studio 2022开发,把下载好后的PotreeDesktop源码添加到Visual Studio中。

打开Visual Studio 2022,新建Asp.Net Core空项目,如下图所示。

Potree 002 Desktop开发环境搭建

点击下一步按钮,设置项目的名称、存储路径以及解决方案名称等。如下图所示。

Potree 002 Desktop开发环境搭建

点击下一步按钮,最后点击创建按钮,完成创建工作。创建后的根目录如下图所示。

Potree 002 Desktop开发环境搭建

打开WOBM.Potree.Web目录,里面的内容如下图所示。

Potree 002 Desktop开发环境搭建

创建的工程在Visual Studio中的工程树如下图所示。

Potree 002 Desktop开发环境搭建

接下来,我们把PotreeDesktop下的源码,拷贝到WOBM.Potree.Web目录下,拷贝后,文件夹组织如下图所示。

Potree 002 Desktop开发环境搭建

从Visual Studio的工程树上,通过添加、删除和项目中排除等方法,最终工程树的组织如下图所示。

Potree 002 Desktop开发环境搭建

目录上不需要的内容,也可以删除,最终保留的内容如下图所示。

Potree 002 Desktop开发环境搭建

此时,双击PotreeDesktop.bat,如果系统能够成功启动,那么开发环境就算搭建成功了,接下来就可以在Visual Studio写我们自定义的html和js代码了。

2、系统启动过程

系统是从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页面,至此完成了我们开发的主页面的加载。

3、Main.js文件内容介绍

在该文件的开始,先定义了一些变量,代码如下。

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。

4、Index.html文件内容介绍

在该文件的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">

接下来引用外部的js文件。

<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>

该页面的html元素定义如下。

<div class="potree_container" style="position: absolute; width: 100%; height: 100%; left: 0px; top: 0px; ">
     <div ></div>
     <div ></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中实现的。

5、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函数加载该文件即可,加载的过程和上面提到的直接加载点云数据一致。