GH python Grasshopper RH Tips Rhinoceros Three.js

【Rhinoceros】作成したモデルをUser Textを持たせたままWebで表示する

今回は、Rhinocerosで作成したモデルにUserTextをもたせ、そのUserTextをもたせたままWebで表示する方法について書いていきます。

概要

ざっくりとした手順は以下のようになってます。

  1. Rhinocerosでモデルを作成しUserTextを持たせる
  2. Grasshopperへ移行しMesh化、またUserTextと割り当てられているMaterialを取得
  3. Grasshopper内でPythonを使用しMeshの頂点・法線・インデックス、またMateriarlとUserTextをJSON化
  4. JSON化したものをPythonのライブラリpygltflibでglb化する。【※ Rhino8はPyrhon3が使用できるのでGrasshopper内でそのまま実行可能ですが、Rhino7以前はJSON化したファイルを保存し、Grasshopper外でpygltflibを実行する必要あり】
  5. Threejsなどで表示

といった感じの手順になります。

Rhino8からgltf , glb形式でのエクスポートがサポートされましたが、UserTextを持たせたままのExportがサポートされていないようなので、Rhinoで作成したモデルを独自でglb形式でExportしuserTextをデータに入れ込むといった感じの内容となってます。

RhniocerosでモデルにUserTextを割り当てる

  • 今回、私は適当な椅子のMeshモデルを使って実装していこうと思います。5つのMeshで構成されているモデルとなっており、座面(1Mesh)と、木フレーム部分(4Mesh)でレイヤ分けをしそれぞれMaterialを割り当てていきます。
  • それぞれのMeshに対してUserTextを割り当てていきます。今回はidを各Meshに対して[ m-1, m-2, m-3, m-4, m-5 ] といった感じで割り当てました。
  • 今回は適当に拾ってきたMeshモデルを使用しますがBrepでも問題ありません。

Grasshopper全体概要

上記がGrasshopperの概要となっております。Rhinocerosで作成したモデルを取得し、glb形式に変換するところまで行っております。上画像最後の「json to glb」はpython3の環境が必要なのでRhino7以前の方は基本的にはGrasshopper外で行う必要があります。

Grrasshopper 必要なデータの取得

【Meshのquad faceをtriangleに変換】

  • まずRhinocerosで作成したモデルをMeshコンポーネントにsetします。もしRhinocerosで作成したモデルがBrepだったら、Brepコンポーネントにsetして、BrepコンポーネントをMeshコンポーネントつないでMesh化してください。
  • 今回私が使用しているMeshデータが、なぜかInvalid Meshになってしまうので、Deconstruct Meshコンポーネントで一度ばらして、Construct Meshコンポーネントで再度Mesh化しました。この手順は通常は必要ないので飛ばして結構です。
  • その後Combine&CleanコンポーネントでMesh数を減らします。この時入力端子をGraftし、5つのMeshが結合されないように注意してください。出力端子はFlattenしときます。
  • glb形式に変換する際に四角形のFaceは対応していないので、Mesh Triangulateコンポーネントで三角形Faceに変換します。

【UserTextの取得】

  • それぞれのMeshに割り当てたUserTextを取得します。ElefrontのGetUserValueコンポーネントでKey(今回はid)を指定しValueを取得します。出力端子はFlattenしときます。(ElefrontはこちらからインストールするかPackageManagerからインストールしてください)

【レイヤのマテリアルを取得】

  • Meshに割り当てたMaterialを取得します。ここに関しては色々なやり方があるかと思います。scriptで処理してもいいと思います。上画像のやり方はやや回りくどいような気もしますので、各々実装してみてください。
  • HumanのObject AttributesコンポーネントでMeshに割り当てられているレイヤ名を取得します。(※Humanはこちらからインストールするか、PackageManagerからインストールしてください、)
  • HumanのLayerTablesコンポーネントでRhino上にあるLayer名をすべて取得し、各MeshがLayerTablesコンポーネントのLayerリストのどっちが割り当てられているのかインデックスで取得します。
  • HumanのLayerTablesコンポーネントの出力端子MからはMaterialNameが取得出るので、Layer名のインデックスからどのMaterialが割り当てられているのかを取得します。
  • HumanのMaterialTableコンポーネントでRhino上のMaterialの名前とDiffuseValueを取得し、割り当てられているマテリアル名から検索してDiffuseの値を取得します。
  • SplitARGBコンポーネントで、取得したDiffuseのRGB(0~1)をMergeしてからFlattenします。(※今回alphaはすべて1.0を前提として実装します。ガラスなど透過する要素がある場合は必要かと思います)

最終的に上記画像のPanelで出力されたように値が取得されていればOkです。

Grasshopper - Python取得したデータをJSON化

  • Pythonコンポーネントで先ほど取得したMesh・UserText・materialを入力します。入力端子はすべてTree Accessにし、それぞれMesh・attributes・colorsという名前で取得しています。
# coding=utf-8
import json
import ghpythonlib.treehelpers as th

meshes = th.tree_to_list(mesh)
attr = th.tree_to_list(attributes)
color = th.tree_to_list(colors)
export_data = []

for index,(mesh, attr_data) in enumerate(zip(meshes,attr)):
    mesh_vertices =[]
    mesh_faces_indices = []
    mesh_normals = []
    mesh_uvs = []
    mesh_material=[
        color[index*3],
        color[index*3+1],
        color[index*3+2]
        ]


    for i in range(mesh.Vertices.Count):
        vertex = mesh.Vertices[i]
        vert_list = [vertex.X, vertex.Y, vertex.Z]
        mesh_vertices.append(vert_list)

        # 頂点法線の取得
        normal = mesh.Normals[i]
        norm_list = [normal.X, normal.Y, normal.Z]
        mesh_normals.append(norm_list)

    for face in mesh.Faces:
        if face.IsTriangle:
            indices = [face.A, face.B, face.C]
        else:
            indices = [face.A, face.B, face.C]
            mesh_faces_indices.append(indices)
            indices = [face.C, face.D, face.A]
        mesh_faces_indices.append(indices)

    
    export_data.append({
        "vertices": mesh_vertices,
        "faces": mesh_faces_indices,
        "normals": mesh_normals,
        "materials":mesh_material,
        "uvs": mesh_uvs,
        "userData": attr_data
    })

      
json_data = json.dumps(export_data)
output = json_data

print("Export completed.")

##RHINO7以前は一度JSONファイルを外部に保存↓
file_path = "C:\\Users\\81803\\Desktop\\export_data3.json"
with open(file_path, "w") as file:
    json.dump(export_data, file)

上記のコードをざっくり説明します。

  • 入力したmesh / attributes / colorsをそれぞれTreeからListに変換します。
  • forループでMeshの数だけループさせます。meshの頂点・法線・Faceのインデックス番号を取得2次元配列で格納します。また、Materialに関しては、diffuseをRGBに分解し、FlattenしたListに格納したので、Listの0, 1, 2番目が最初のMeshのRGB, 3, 4, 5が2番目のMeshのRGBといった感じになるので、上記のようなコードになります。(※uvについては、今回は使用しないので空の配列になってますがテクスチャなどもExportする際は必要になってくるかと思います。)
  • 1つのMeshデータを辞書型でまとめで、export_dataの配列にappendします。
  • その後、json.dumpsでJSON化します。
  • Rhino8の型はoutputという出力端子を作成してそのままJSONデータを出力し、このデータをもとにpygltlibを使用してGrasshopper内でglb形式でモデルをエクスポートしていきます。Rhino7以前の場合、Python3が基本的には使えないので、JSON化したデータを任意の場所に保存して、Grasshopper外でPythonの環境を構築して、glb変換を行います。

出力されるJSONデータがどのようになるかはこちらのgithubリポジトリのsample dataに入ってます。ざっくり説明すると以下のように5つのMeshデータが配列に格納され、JSON形式に変換されてます。

[
  {
    "vertices": [
      [-219.17027282714844, 430.4172058105469, 285.9709167480469],
      [-219.17027282714844, 430.4172058105469, 258.9709167480469],
      [-219.17027282714844, 400.4172058105469, 285.9709167480469],
      [-219.17027282714844, 400.4172058105469, 258.9709167480469],
      [-219.17027282714844, -99.5827865600586, 285.9709167480469],
      ....たくさん頂点ある
    [-679.1702880859375, -129.58277893066406, 285.9709167480469],
      [-679.1702880859375, -129.58277893066406, 258.9709167480469]
    ],
    "faces": [
      [107, 105, 3],
      [107, 5, 7],
      [7, 11, 99],
      ....たくさん頂点インデックスある
      [11, 15, 59],
      [1, 8, 0],
      [109, 104, 108]
    ],
    "normals": [
      [0.3712981045246124, 0.0060758935287594795, 0.928493857383728],
      [0.7069647908210754, 0.011568717658519745, -0.7071540951728821],
      ....たくさん法線ある
      [-0.5545745491981506, -0.009075014851987362, 0.832084596157074],
      [-0.4471058249473572, -0.0073164054192602634, -0.8944511413574219]
    ],
    "materials": [0.807843137254902, 0.6549019607843137, 0.4470588235294118],
    "uvs": [],
    "userData": "m-1"
  },
  {
    "vertices": [
      ....たくさん頂点ある
    ],
    "faces": [
      ....たくさん頂点のインデックスある
    ],
    "normals": [
      ....たくさん法線ある
    ],
    "materials": [0.807843137254902, 0.6549019607843137, 0.4470588235294118],
    "uvs": [],
    "userData": "m-2"
  },
  ....のこり3つのMeshも同様
]
      

gltfデータの概要

JSONデータを元にglbデータを生成していきます。私自身も深く理解ができてないので、説明が不足してしまうこともあるかもしれませんのでご了承ください。詳しく知りたい方はこちらのGLTFデータの開発元であるKhronos Groupのrepositoryを参照してください。Tutorialなどもあるのでぜひご参考ください。

まず初めにglb形式とはgltf形式のデータをバイナリ化したものになります。なので、まずはgltfデータを生成する必要があります。

{
  "scene": 0,
  "scenes" : [
    {
      "nodes" : [ 0 ]
    }
  ],
  
  "nodes" : [
    {
      "mesh" : 0
    }
  ],
  
  "meshes" : [
    {
      "primitives" : [ {
        "attributes" : {
          "POSITION" : 1
        },
        "indices" : 0
      } ]
    }
  ],

  "buffers" : [
    {
      "uri" : "data:application/octet-stream;base64,AAABAAIAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAA=",
      "byteLength" : 44
    }
  ],
  "bufferViews" : [
    {
      "buffer" : 0,
      "byteOffset" : 0,
      "byteLength" : 6,
      "target" : 34963
    },
    {
      "buffer" : 0,
      "byteOffset" : 8,
      "byteLength" : 36,
      "target" : 34962
    }
  ],
  "accessors" : [
    {
      "bufferView" : 0,
      "byteOffset" : 0,
      "componentType" : 5123,
      "count" : 3,
      "type" : "SCALAR",
      "max" : [ 2 ],
      "min" : [ 0 ]
    },
    {
      "bufferView" : 1,
      "byteOffset" : 0,
      "componentType" : 5126,
      "count" : 3,
      "type" : "VEC3",
      "max" : [ 1.0, 1.0, 0.0 ],
      "min" : [ 0.0, 0.0, 0.0 ]
    }
  ],
  
  "asset" : {
    "version" : "2.0"
  }
}

上記のデータはシンプルな三角形を構成するgltfデータの中身で、Khronos Groupのチュートリアルから参照しています。

また上画像はgltfの基本的な構造であり、KhronosGroupのチュートリアルから参照させてもらっています。

ざっくり構造を説明すると、

  • Sceneがgltfの記述のエントリーポイントとなっており、sceneはnodeを参照しています。
  • nodeはMeshやカメラ、skin(※モデル自体にアニメーションが含まれる際にどのように動くか)などを参照し、それらの事態の位置、回転、スケールなどを定義してます。
  • Meshはシーンに表示されるオブジェクトで、Materialやaccessorを参照してます。
  • accessorはバッファ内の特定のデータセットにアクセスするための情報をまとめており、bufferViewを参照しています。
  • bufferViewはbuffer内のどの部分を参照しているかを記述しておりbufferを参照しています。
  • bufferは頂点データや・法線・インデックスなどをバイナリ化したものが格納されています。

めちゃくちゃざっくり説明するとこんな感じだと思いますが、詳しくはKhronos Groupのリポジトリを読んでみてください。

jsonをglbに変換

import numpy
import pygltflib
import json
import numpy as np
import ghpythonlib.treehelpers as th

def convert_to_y_up(points):
    """Z-UpからY-Upへ座標変換"""
    return np.array([[x, z, y] for x, y, z in points])

attr = th.tree_to_list(json_data)
meshes_data = json.loads(attr[0])

pointsArray= []
normalsArray = []
facesArray = []
userDataArray = []
materialsArray = []

for mesh_data in meshes_data:
    # 頂点データを準備
    vertices = convert_to_y_up(mesh_data["vertices"])
    npArray = np.array(vertices, dtype="float32")
    pointsArray.append(npArray)

    # 法線データを準備
    normals = convert_to_y_up(mesh_data["normals"])  # 法線も座標変換が必要
    npNormals = np.array(normals, dtype="float32")
    normalsArray.append(npNormals)

    # 頂点のインデックスを準備
    faces = mesh_data["faces"]
    npFaces = np.array(faces, dtype="uint32")
    facesArray.append(npFaces)

    # userDataを準備
    userData = mesh_data["userData"]
    userDataArray.append(userData)

    # マテリアルを準備
    normalized_color_data = mesh_data["materials"]
    normalized_color_data.append(1.0)

    material = pygltflib.Material(
        pbrMetallicRoughness=pygltflib.PbrMetallicRoughness(
            baseColorFactor=normalized_color_data,
            metallicFactor=0.0, 
            roughnessFactor=1.0  
    ),
    doubleSided=True
    )
    materialsArray.append(material)
    

binary_blob = bytearray()
for points, normals,faces in zip(pointsArray, normalsArray, facesArray):

    points_binary_blob = points.tobytes()
    normals_binary_blob = normals.tobytes()
    faces_binary_blob = faces.flatten().tobytes()

    binary_blob.extend(faces_binary_blob)
    binary_blob.extend(points_binary_blob)
    binary_blob.extend(normals_binary_blob)

buffer = pygltflib.Buffer(byteLength=len(binary_blob))

# バッファビューの作成
bufferViews = []
byte_offset = 0
accessors = []

for i, (points, normals, faces) in enumerate(zip(pointsArray, normalsArray, facesArray)):
    faces_byte_length = len(faces.flatten().tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=faces_byte_length,
        target=pygltflib.ELEMENT_ARRAY_BUFFER
    ))
    byte_offset += faces_byte_length

    # 頂点位置データのバッファビュー
    points_byte_length = len(points.tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=points_byte_length,
        target=pygltflib.ARRAY_BUFFER
    ))
    byte_offset += points_byte_length

    # 法線データのバッファビュー
    normals_byte_length = len(normals.tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=normals_byte_length,
        target=pygltflib.ARRAY_BUFFER
    ))
    byte_offset += normals_byte_length

    accessors.extend([
        pygltflib.Accessor(
            bufferView=i * 3,
            componentType=pygltflib.UNSIGNED_INT,
            count=len(faces.flatten()),
            type=pygltflib.SCALAR,
            max=[int(faces.max())],
            min=[int(faces.min())],
        ),
        pygltflib.Accessor(
            bufferView=i * 3 + 1,
            componentType=pygltflib.FLOAT,
            count=len(points),
            type=pygltflib.VEC3,
            max=points.max(axis=0).tolist(),
            min=points.min(axis=0).tolist(),
        ),
        pygltflib.Accessor(
            bufferView=i * 3 + 2,
            componentType=pygltflib.FLOAT,
            count=len(normals),
            type=pygltflib.VEC3,
            max=normals.max(axis=0).tolist(),
            min=normals.min(axis=0).tolist(),
        )
    ])


# メッシュとノードの作成
meshes = []
nodes = []

for i, userData in enumerate(userDataArray):
    mesh_index = len(meshes)
    meshes.append(pygltflib.Mesh(
        primitives=[pygltflib.Primitive(attributes=pygltflib.Attributes(POSITION=i * 3 + 1,NORMAL=i * 3 + 2), indices=i * 3, material = i)]
    ))
    meshes[mesh_index].extras = {"id": userData}
    nodes.append(pygltflib.Node(mesh=mesh_index))

gltf = pygltflib.GLTF2(
    scene=0,
    scenes=[pygltflib.Scene(nodes=list(range(len(nodes))))],
    nodes=nodes,
    meshes=meshes,
    materials=materialsArray,
    accessors=accessors,
    bufferViews=bufferViews,
    buffers=[buffer]
)


# バイナリブロブをGLTFに設定
gltf.set_binary_blob(bytes(binary_blob))

# GLBファイルの保存
gltf.save("C:\\Users\\81803\\Desktop\\test_model.glb")
print("export fin")

上記のコードが先ほど生成したjsonファイルをglbに変換するコードとなっております。私はRhnio8なのでGrasshopper内で実装してますが、Rhino7以前のかたはexportしたjsonファイルを外部で環境構築したpython環境で実行する必要があると思います。またその場合、上記のコードの最初のjsonデータの読み込み方が変わってくるかと思いますのでご注意ください。

上記のコードで、Rhno8環境でnumpyやpygltflibなどの外部ライブラリを使用してますが、numpyはもとから入ってたような気がしますが、pygltflibfが自分で入れたのか元から入ってたか記憶が定かではなく、もし最初から入ってなければrhinocerosが参照しているpythonのライブラリーにぶち込む必要があるかもです。また、numpyなどを最初実行する際は、こちらを参照していただき、もしかしたら # r: numpy の記述がないと実行できないかもしれませんのでご注意ください。

では上記のコードを解説していきます。

基本的には、前項で説明したgltfの構造を下から実装していきます。

【jsonデータを整理】

まずはjson生成したjsonデータをばらして整理していきます。pythonの入力端子にjson_dataという端子をつくり、Tree Accessで入力します。

これらを配列直して、json.loadでjsonデータをparseしmeshes_dataとして取得します。

これらmeshes_dataに格納されているデータをforループでまわし、頂点・法線・インデックス・マテリアル(diffuse)・userTextにそれぞれ配列で格納しますが、threejsでロードしようと思っており、

threejs上ではY方向が上なので、z成分とy成分をconvert_to_y_upで入れ替えてます。

materialに関しては、最初にnormalized_color_dataに以前json化したRGBの値を配列で取得し、そこにalpha = 1.0 を配列の最後にappendしています。

その後pygltflibを使ってmaterialを作成し、baseColorFactorに配列で格納したRGBAの値をセットし、metaric=0.0とroughness=1.0をセットしております。

また、マテリアルはweb上で両面レンダリングしたいので、doubleSided=Trueとしています。

【頂点・法線・インデックスをバイナリ形化】

頂点や法線・インデックスをバイナリ形式にしていきます。binary_blob = bytearray()でbinaryオブジェクトを生成します。ここにバイナリデータを格納していきます。

先ほど取得した頂点の座標・法線・インデックスをforループで回してバイナリ化したものをbytearray()にappendしてます。

注意する点としては、頂点座標と法線は[x,y,z]成分をもつデータの配列で扱いますが、インデックスに関してはgltf上ではflattenして1次元配列で扱うようです。

buffer = pygltflib.Buffer(byteLength=len(binary_blob))でバイナリデータを格納するためのbufferを作成してます。bufferのサイズはlen(binary_blob)で生成したバイナリのlengthを指定してます。

【bufferViewの作成】

bufferはバイナリデータが格納されており2進数、あるいは16進数などに変換したも単一の文字列が格納されていると思ってください。

その文字列の中のどこからどこまでが何のデータでどのような値が入っているかを参照しているのがbufferViewになります。69行目から101行目でbufferViewを生成しています。

face(インデックス)points(頂点座標) normal(法線)の順番で作成しております。まずfaceを少し詳しく見ていきます。

bufferViews = []
byte_offset = 0
accessors = []

for i, (points, normals, faces) in enumerate(zip(pointsArray, normalsArray, facesArray)):
    faces_byte_length = len(faces.flatten().tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=faces_byte_length,
        target=pygltflib.ELEMENT_ARRAY_BUFFER
    ))
    byte_offset += faces_byte_length

まず、faces_byte_lengthでforループで取得したfaceをバイナリーに変換した際のlengthを取得します。

pygltflib.BufferViewでbufferViewを生成しています。buffer=0は先ほど生成したバイナリデータを参照しており、bytearray()に1つのバイナリーデータしか格納されていないため0番目が指定されています。

byteOffset=byte_offsetは参照しているbufferのバイナリーデータのスタート地点です。forループの一番最初は0番目がスタートになります。

byteLength=faces_byte_lengthは参照しているバイナリーデータの終わり位置です。一番最初のfaceデータは0番目がスタートでfaces_byte_lengthまでが参照する対象になります。

target=pygltflib.ELEMENT_ARRAY_BUFFERは参照するバイナリにどのようなデータが格納されているかを示しています。今回はインデックスなのでpygltflib.ELEMENT_ARRAY_BUFFERとなります。

その後、次のデータの参照するバイナリーデータの開始位置を byte_offset += faces_byte_lengthで更新しています。

次にpointsとnormalを見てみます。

# バッファビューの作成
bufferViews = []
byte_offset = 0
accessors = []

for i, (points, normals, faces) in enumerate(zip(pointsArray, normalsArray, facesArray)):
    faces_byte_length = len(faces.flatten().tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=faces_byte_length,
        target=pygltflib.ELEMENT_ARRAY_BUFFER
    ))
    byte_offset += faces_byte_length

    # 頂点位置データのバッファビュー
    points_byte_length = len(points.tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=points_byte_length,
        target=pygltflib.ARRAY_BUFFER
    ))
    byte_offset += points_byte_length

    # 法線データのバッファビュー
    normals_byte_length = len(normals.tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=normals_byte_length,
        target=pygltflib.ARRAY_BUFFER
    ))
    byte_offset += normals_byte_length

pointsもnormalも基本的にはfaceと同じで、バイナリーのlengthを求めて、参照するバイナリの位置を指定してます。違うのはtargetですが、頂点・法線はxyzの3成分を持つVec3型なのでtarget=pygltflib.ARRAY_BUFFERとしています。

【accessorsの生成】

accessorsはどのbufferViewを参照しているかまとめているものになります。先ほどのforループの続きで生成しています。以下で詳しく見ていきましょう。

bufferViews = []
byte_offset = 0
accessors = []

for i, (points, normals, faces) in enumerate(zip(pointsArray, normalsArray, facesArray)):
    faces_byte_length = len(faces.flatten().tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=faces_byte_length,
        target=pygltflib.ELEMENT_ARRAY_BUFFER
    ))
    byte_offset += faces_byte_length

    # 頂点位置データのバッファビュー
    points_byte_length = len(points.tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=points_byte_length,
        target=pygltflib.ARRAY_BUFFER
    ))
    byte_offset += points_byte_length

    # 法線データのバッファビュー
    normals_byte_length = len(normals.tobytes())
    bufferViews.append(pygltflib.BufferView(
        buffer=0,
        byteOffset=byte_offset,
        byteLength=normals_byte_length,
        target=pygltflib.ARRAY_BUFFER
    ))
    byte_offset += normals_byte_length

    accessors.extend([
        pygltflib.Accessor(
            bufferView=i * 3,
            componentType=pygltflib.UNSIGNED_INT,
            count=len(faces.flatten()),
            type=pygltflib.SCALAR,
            max=[int(faces.max())],
            min=[int(faces.min())],
        ),
        pygltflib.Accessor(
            bufferView=i * 3 + 1,
            componentType=pygltflib.FLOAT,
            count=len(points),
            type=pygltflib.VEC3,
            max=points.max(axis=0).tolist(),
            min=points.min(axis=0).tolist(),
        ),
        pygltflib.Accessor(
            bufferView=i * 3 + 2,
            componentType=pygltflib.FLOAT,
            count=len(normals),
            type=pygltflib.VEC3,
            max=normals.max(axis=0).tolist(),
            min=normals.min(axis=0).tolist(),
        )
    ])

accessors.extendで生成したaccessorsを追加してます。accessorはmeshに参照され、結果的にmeshが参照する頂点のインデックス・頂点座標・法線を示すことになります。

上記のコードでもpygltflib.Accessorが3つ生成され配列で格納されてます。0番目がMeshが参照する頂点のインデックス情報の1番目が頂点座標の情報2番目が法線の情報となってます。

0番目を詳しく見ていくと、bufferView=i * 3で参照するbufferViewの要素のインデックスを指定してます。先ほどbufferViewの配列を生成する際にface => points => normalsの順番で配列に格納したので、faceはi * 3番目になります。

componentType=pygltflib.UNSIGNED_INTは値の型を指定してます。インデックスは整数なので UNSIGNED_INTになります。countはこのaccsessorが含む要素の数を指定してます。type=pygltflib.SCALARはデータタイプを示しており、SCALARは単一の要素が値になっていることを示します。minとmaxは参照しているデータの最小値・最大値を示します。バウンディングボックスなどを計算する際に使うようです。

points、normalも同様ですが、bufferViewの位置がとデータの型がVec3型で単一要素の型がfloat型になってます。

【Meshとnodeを作成】

MeshとNodeを作成していきます。

# メッシュとノードの作成
meshes = []
nodes = []

for i, userData in enumerate(userDataArray):
    mesh_index = len(meshes)
    meshes.append(pygltflib.Mesh(
        primitives=[pygltflib.Primitive(attributes=pygltflib.Attributes(POSITION=i * 3 + 1,NORMAL=i * 3 + 2), indices=i * 3, material = i)]
    ))
    meshes[mesh_index].extras = {"id": userData}
    nodes.append(pygltflib.Node(mesh=mesh_index))

上記のコードでmeshとnodeを作ってます。上記のコードはuserDataの数でforループ回してますが、すべてのMeshにuserDataを格納している前提なのでこのようにしてます。本来はMeshの配列でforループを回して、userDataが存在したら格納するみたいにしたほうが良かったかもしれませんが、とりあえずこれで進めます。

mesh_index = len(meshes)でmeshのインデックス番号を生成します。

7~9行までで一気にMeshを生成しています。MeshをPrimitive(基本形状)などの情報を持たせ、Attributesで先ほど作成したaccsessorの位置と以前作成したmaterial配列のindex番号を指定してます。accsessorはインデックス=>頂点=>法線の順番で配列に格納したので、上記のような記述になってます。

そして、ここがこの記事で一番やりたかったことなんですが、userTextを持たせたまま、webにモデルを持っていく必要があります。gltfではmeshに限らず、sceneでもnodeでもextrasにUserが独自にデータを格納することができます。Rhinocerosが提供しているgltf / glbのエクスポートはこの部分をサポートしてくれていないので、やむなく独自で実装したといった感じです。今回はkeyをidにし、Rhinocerosから取得してきたuserDataをvalueとして格納します。

最後にpygltflib.NodeでNodeを生成して、参照するMeshのindex番号渡してあげます。

ちなみに結構昔ですがこちら以下はfbxで話は出たみたいです・・・

【glbデータの作成】

gltf = pygltflib.GLTF2(
    scene=0,
    scenes=[pygltflib.Scene(nodes=list(range(len(nodes))))],
    nodes=nodes,
    meshes=meshes,
    materials=materialsArray,
    accessors=accessors,
    bufferViews=bufferViews,
    buffers=[buffer]
)


# バイナリブロブをGLTFに設定
gltf.set_binary_blob(bytes(binary_blob))

# GLBファイルの保存
gltf.save("C:\\Users\\81803\\Desktop\\test_model.glb")
print("export fin")

最後にglbデータを生成します。pygltflib.GLTF2でgltfのインスタンスを生成し、いままで生成してきたものを入れていきます。

gltf.set_binary_blob(bytes(binary_blob))で以前生成したbytearray()をbytes型に変換してgltfにセットします。

最後に、gltf.save("C:\Users\81803\Desktop\test_model.glb") で任意の場所に任意の名前でglbデータを保存して完了です。

Three.jsで表示してみる

作成したデータをThree.jsで読み込んでみたのが上のgifになります。それぞれのMeshをクリックするとMeshに割り当てたUserTextが左上に表示されるように実装しています。

また、上記画像でMeshのUserDataにRhinocerosで指定したUserTextが格納されているのがわかります。

Threejs自体は詳しく説明しませんが、React Three Fiberを使用してglbモデルを表示する記事を以前書いたので良ければ参考にしてください。また上のgithubリポジトリはこちらになるので、ご参照ください。

以上になります。gltfの理解が浅いので、もしかしたら説明が不足しているかもしれません。その際はKhronosGroupのリポジトリをぜひのぞいてみてください。

【参考】

夢は大きく. 対象コースが¥1,600から。

-GH python, Grasshopper, RH Tips, Rhinoceros, Three.js