基於 HTML5 WebGL 的 3D 模型斜面生成

圖撲軟體發表於2019-03-11

前言

3D 場景中的面不只有水平面這一個,空間是由無數個面組成的,所以我們有可能會在任意一個面上放置物體,而空間中的面如何確定呢?我們知道,空間中的面可以由一個點和一條法線組成。這個 Demo 左側為皮膚,從皮膚中拖動物體到右側的 3D 場景中,當然,我滑鼠拖動到的位置就是物體放置的點,但是這次我們的重點是如何在斜面上放置模型。

效果圖:
圖片描述

程式碼生成

建立場景

dm = new ht.DataModel(); // 資料模型(http://hightopo.com/guide/guide/core/datamodel/ht-datamodel-guide.html)
g3d = new ht.graph3d.Graph3dView(dm); // 3D 場景元件(http://hightopo.com/guide/guide/core/3d/ht-3d-guide.html)
palette = new ht.widget.Palette(); // 皮膚元件(http://hightopo.com/guide/guide/plugin/palette/ht-palette-guide.html)
splitView = new ht.widget.SplitView(palette, g3d, 'h', 0.2); // 分割元件,第三個引數為分割的方式 h 為左右分,v 為上下分;第四個引數為分割比例,大於 1 的值為絕對寬度,小於 1 則為比例
splitView.addToDOM();//將分割元件新增進 body 體中

關於這些元件的定義可以到對應的連結裡面檢視,至於將分割元件新增進 body 體中的 addToDOM 函式有必要解釋一下(我每次都提,這個真的很重要!)。

HT 的元件一般都會嵌入 BorderPane、SplitView 和 TabView 等容器中使用,而最外層的 HT 元件則需要使用者手工將 getView() 返回的底層 div 元素新增到頁面的 DOM 元素中,這裡需要注意的是,當父容器大小變化時,如果父容器是 BorderPane 和 SplitView 等這些HT預定義的容器元件,則 HT 的容器會自動遞迴呼叫孩子元件 invalidate 函式通知更新。但如果父容器是原生的 html 元素, 則 HT 元件無法獲知需要更新,因此最外層的 HT 元件一般需要監聽 window 的視窗大小變化事件,呼叫最外層元件 invalidate 函式進行更新。

為了最外層元件載入填充滿視窗的方便性,HT 的所有元件都有 addToDOM 函式,其實現邏輯如下,其中 iv 是 invalidate 的簡寫:

addToDOM = function(){
    var self = this,
        view = self.getView(), // 獲取元件的底層 div
        style = view.style;
    document.body.appendChild(view); // 將元件底層 div 新增進 body 中
    style.left = '0'; // ht 預設將所有的元件的 position 都設定為 absolute 絕對定位
    style.right = '0';
    style.top = '0';
    style.bottom = '0';
    window.addEventListener('resize', function () { self.iv(); }, false); // 視窗大小改變事件,呼叫重新整理函式
}

大家可能注意到了,場景中我新增的斜面實際上就是一個 ht.Node 節點,作為與地平面的參照,在這樣的對比下立體感會更強一點。下面是這個節點的定義:

node = new ht.Node();
node.s3(1000, 1, 1000); // 設定節點的大小
node.r3(0, 0, Math.PI/4); // 設定節點旋轉 這個旋轉的角度是有學問的,跟下面我們要設定的拖拽放置的位置有關係
node.s('3d.movable', false); // 設定節點在 3d 上不可移動 因為這個節點只是一個參照物,建議是不允許移動
dm.add(node); // 將節點新增進資料容器中

左側內容構建

圖片描述
Palette 和 GraphView 類似,由 ht.DataModel 驅動,用 ht.Group 展示分組,ht.Node 展示按鈕元素。我將載入 Palette 皮膚中的圖元函式封裝為 initPalette,定義如下:

function initPalette() { // 載入 palette 皮膚元件中的圖元
    var arrNode = ['displayDevice', 'cabinetRelative', 'deskChair', 'temperature', 'indoors', 'monitor','others'];
    var nameArr = ['展示設施', '機櫃相關', '桌椅儲物', '溫度控制', '室內', '視訊監控', '其他']; // arrNode 中的 index 與 nameArr 中的一一對應
    
    for (var i = 0; i < arrNode.length; i++) {
        var name = nameArr[i];
        var vName = arrNode[i];

        arrNode[i] = new ht.Group(); // palette 皮膚是將圖元都分在“組”裡面,然後向“組”中新增圖元即可
        palette.dm().add(arrNode[i]); // 向 palette 皮膚元件中新增 group 圖元
        arrNode[i].setExpanded(true); // 設定分組為開啟的狀態
        arrNode[i].setName(name); // 設定組的名字 顯示在分組上
        
        var imageArr = [];
        switch(i){ // 根據不同的分組設定每個分組中不同的圖元
            case 0:
                imageArr = ['models/機房/展示設施/大屏.png'];
                break;
            case 1: 
                imageArr = ['models/機房/機櫃相關/配電箱.png', 'models/機房/機櫃相關/室外天線.png', 'models/機房/機櫃相關/機櫃1.png', 
                            'models/機房/機櫃相關/機櫃2.png', 'models/機房/機櫃相關/機櫃3.png', 'models/機房/機櫃相關/機櫃4.png', 
                            'models/機房/機櫃相關/電池櫃.png'];
                break;
            case 2: 
                imageArr = ['models/機房/桌椅儲物/儲物櫃.png', 'models/機房/桌椅儲物/桌子.png', 'models/機房/桌椅儲物/椅子.png'];
                break;
            case 3: 
                imageArr = ['models/機房/溫度控制/空調精簡.png', 'models/機房/消防設施/消防裝置.png'];
                break;
            case 4:
                imageArr = ['models/室內/辦公桌簡易.png', 'models/室內/書.png', 'models/室內/辦公桌映象.png', 'models/室內/辦公椅.png'];
                break;
            case 5:
                imageArr = ['models/機房/視訊監控/攝像頭方.png', 'models/機房/視訊監控/對講維護攝像頭.png', 'models/機房/視訊監控/微型攝像頭.png'];
                break;
            default: 
                imageArr = ['models/其他/訊號塔.png'];
                break;
        }
        setPalNode(imageArr, arrNode[i]); // 建立 palette 上節點及設定名稱、顯示圖片、父子關係
    }
}

我在 setPalNode 函式中做了一些名稱的設定,主要是想要根據上面 initPalette 函式中我傳入的路徑名稱來設定模型的名稱以及在不同檔案在不同的資料夾下的路徑:

function setPalNode(imageArr, arr) {
    for (var j = 0; j < imageArr.length; j++) {
        var imageName = imageArr[j];
        var jsonUrl = imageName.slice(0, imageName.lastIndexOf('.')) + '.json'; // shape3d 中的 json 路徑
        var name = imageName.slice(imageName.lastIndexOf('/')+1, imageName.lastIndexOf('.')); // 取最後一個/和.之間的字串用來設定節點名稱
        var url = imageName.slice(imageName.indexOf('/')+1, imageName.lastIndexOf('.')); // 取第一個/和最後一個.之間的字串用來設定拖拽生成模型 obj 檔案的路徑
        
        createNode(name, imageName, arr, url, jsonUrl); // 建立節點,這個節點是顯示在 palette 皮膚上
    }
}

createNode 建立節點的函式比較簡單:

function createNode(name, image, parent, urlName, jsonUrl) { // 建立 palette 皮膚元件上的節點
    var node = new ht.Node();
    palette.dm().add(node);
    node.setName(name); // 設定節點名稱 palette 皮膚上顯示的文字也是通過這個屬性設定名稱
    node.setImage(image); // 設定節點的圖片
    node.setParent(parent); // 設定父親節點
    node.s({
        'draggable': true, // 設定節點可拖拽
        'image.stretch': 'centerUniform', // 設定節點圖片的繪製方式
        'label': '' // 設定節點的 label 為空,這樣即使設定了 name 也不會顯示在 3d 中的模型下方
    });
    node.a('urlName', urlName); // a 設定使用者自定義屬性
    node.a('jsonUrl', jsonUrl);
    return node;
}

雖然簡單,但是還是要提一下,draggable: true 為設定節點可拖拽,否則節點不可拖拽;還有 node.s 是 HT 預設封裝好的樣式設定方法,如果使用者需要自己新增方法,則可通過 node.a 方法來新增,引數一為使用者自定義名稱,引數二為使用者自定義值,不僅能傳常量,也能傳變數、物件,還能傳函式!又是一個非常強大的功能。

拖拽功能

拖拽基本上就是響應 windows 自帶的 dragover 以及 drop 事件,要在放開滑鼠的時候建立模型,就要在事件觸發時生成模型:

function dragAndDrop() { // 拖拽功能
    g3d.getView().addEventListener("dragover", function(e) { // 拖拽事件
        e.dataTransfer.dropEffect = "copy";
        handleOver(e);
    });
    g3d.getView().addEventListener("drop", function(e) { // 放開滑鼠事件
        handleDrop(e);
    });
}

function handleOver(e) {
    e.preventDefault(); // 取消事件的預設動作。
}

function handleDrop(e) { // 滑鼠放開時
    e.preventDefault(); // 取消事件的預設動作。
    
    var paletteNode = palette.dm().sm().ld(); // 獲取 palette 皮膚中最後選中的節點
    if (paletteNode) {
        loadObjFunc('assets/objs/' + paletteNode.a('urlName') + '.obj', 'assets/objs/' + paletteNode.a('urlName') + '.mtl', 
                             paletteNode.a('jsonUrl'), g3d.getHitPosition(e, [0, 0, 0], [-1, 1, 0])); // 載入obj模型
    }
}

這裡完全有必要說明一下,這個 Demo 的重點來了! loadObjFunc 函式中的最後一個引數為生成模型的 position3d 座標,g3d.getHitPosition 這個方法總共有三個引數,第一個引數為事件型別,第二和第三個引數如果不設定,則預設為水平面的中心點也就是 [0, 0, 0] 以及法線為 y 軸,也就是 [0, 1, 0],一條法線和一個點就可以確定一個面,所以我們通過這個方法來設定這個節點所要放置的平面是在哪一個面上,我前面將 node 節點設定為繞 z 軸旋轉 45° 角,所以這邊的法線也就要好好想想如何設定了,這是數學上的問題,要自己思考了。

載入模型

HT 通過 ht.Default.loadObj 函式來載入模型,但是前提是要有一個節點,然後再在這個節點上載入模型:

function loadObjFunc(objUrl, mtlUrl, jsonUrl, p3) { // 載入 obj 模型
    var node = new ht.Node();
    var shape3d = jsonUrl.slice(jsonUrl.lastIndexOf('/')+1, jsonUrl.lastIndexOf('.'));
    
    ht.Default.loadObj(objUrl, mtlUrl, { // HT 通過 loadObj 函式來載入 obj 模型
        cube: true, // 是否將模型縮放到單位 1 的尺寸範圍內,預設為 false
        center: true, // 模型是否居中,預設為 false,設定為 true 則會移動模型位置使其內容居中
        shape3d: shape3d, // 如果指定了 shape3d 名稱,則 HT 將自動將載入解析後的所有材質模型構建成陣列的方式,以該名稱進行註冊
        finishFunc: function(modelMap, array, rawS3) { // 用於載入後的回撥處理
            if (modelMap) {
                node.s({ // 設定節點樣式
                    'shape3d': jsonUrl, // jsonUrl 為 obj 模型的 json 檔案路徑
                    'label': '' // 設定 label 為空,label 的優先順序高於 name,所以即使設定了 name,節點的下方也不會顯示 name名稱
                });
                g3d.dm().add(node); // 將節點新增進資料容器中

                node.s3(rawS3); // 設定節點大小 rawS3 模型的原始尺寸
                node.p3(p3); // 設定節點的三維座標
                node.setName(shape3d); // 設定節點名稱
                node.setElevation(node.s3()[1]/2); // 控制 Node 圖元中心位置所在 3D 座標系的y軸位置
                g3d.sm().ss(node); // 設定選中當前節點
                g3d.setFocus(node); // 將焦點設定在當前節點上
                return node;
            }
        }
    });
}

程式碼結束!

總結

說實在的這個 Demo 真的是非常容易,難度可能在於空間思維能力了,先確認法線和點,然後根據法線和點找到那個面,這個面按照我的這種方式有個對照還比較能夠理解,真幻想的話,可能容易串。這個 Demo 容易主要還是因為封裝的 hitPosition 函式簡單好用,這個真的是功不可沒。

相關文章