Potree 002 Desktop開發環境搭建

2023-01-02 15:01:34

1、工程建立

我們使用Visual Studio 2022開發,把下載好後的PotreeDesktop原始碼新增到Visual Studio中。

開啟Visual Studio 2022,新建Asp.Net Core空專案,如下圖所示。

截圖.png

點選下一步按鈕,設定專案的名稱、儲存路徑以及解決方案名稱等。如下圖所示。

截圖.png

點選下一步按鈕,最後點選建立按鈕,完成建立工作。建立後的根目錄如下圖所示。

截圖.png

開啟WOBM.Potree.Web目錄,裡面的內容如下圖所示。

截圖.png

建立的工程在Visual Studio中的工程樹如下圖所示。

截圖.png

接下來,我們把PotreeDesktop下的原始碼,拷貝到WOBM.Potree.Web目錄下,拷貝後,資料夾組織如下圖所示。

截圖.png

從Visual Studio的工程樹上,通過新增、刪除和專案中排除等方法,最終工程樹的組織如下圖所示。

截圖.png

目錄上不需要的內容,也可以刪除,最終保留的內容如下圖所示。

截圖.png

此時,雙擊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 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();
});

系統啟動後,整個系統的主介面就出來了,如下圖所示。

截圖.png

左側為功能面板區,是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;
}

這三個函數定義的比較簡單,當拖到主區域的時候,顯示拖拽區域,當移動到主區域的時候,也顯示拖拽區域,當離開的時候,則隱藏該區域。顯示拖拽區域的效果如下圖所示。

截圖.png

當滑鼠放下之後,系統會呼叫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函數載入該檔案即可,載入的過程和上面提到的直接載入點雲資料一致。