iPhone/iPadの新機能「RoomPlan」でRevitモデルを生成しよう！


# はじめに
__[DANSO（建築情報学会） Advent Calendar 2022](https://qiita.com/advent-calendar/2022/ais-j-danso)__ の2日目の記事です。
この記事ではRoomPlanについて簡単に紹介し、DynamoをつかってRevitモデルに変換する方法を解説します。

※2024/06/11追記：C#で実装したアドインの動画もあります
<iframe width="560" height="315" src="https://www.youtube.com/embed/m0KzD-X0cRo?si=Cefm6gfdSSFWntqJ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


# RoomPlan とは
iOS16/iPadOS16の新機能である __RoomPlan__ は、iPhoneやiPadのLiDARスキャナを使って屋内空間をスキャンするためのAPIです。家具の種類や部屋のサイズなどの要素を含む3Dモデルを生成することができます。

https://developer.apple.com/jp/augmented-reality/roomplan/

このRoomPlanに対応した機能が3Dスキャナーアプリ __「Polycam」__ に追加され、今年９月から一般ユーザも使えるようになりました。

https://poly.cam/

:::note warn
* Polycamは無料でインストールできますが、RoomPlanの機能やGLTF以外のデータ出力は有料版が必要です。
* LiDARスキャナ搭載のiPhone ProもしくはiPad Proが必要です。
:::



# スキャンしてみる
大学の研究室で実際にスキャンを行ってみます。

1. Polycamアプリを起動して、画面右下の「ROOM」を選択し、スキャンを開始します。
![IMG_0048.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/2c7cd0c6-b272-43b8-9e2c-3934911561b0.png)

2. カメラで部屋を映していくと白いガイドラインが表示され、床、壁、テーブルなどのオブジェクトが生成されていきます。
![IMG_00501.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/abe2911c-8d84-4076-21bb-a84b737a117d.png)

3. 完成したモデルはアプリで閲覧することができます。
![IMG_0051.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/e8d4d40d-0e46-1501-da54-d11887b1c4bb.png)



# スキャンデータの出力
スキャン結果はOBJやGLTF、FBXのようなメッシュモデルとして出力できます。
![IMG_0053.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/5dd9087c-8910-2c59-b37e-59699784a2f5.png)

また、2D CADデータ（DXF）として出力してRevitなどのアプリで読み込むこともできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/bfc3ac4e-ddac-42de-e627-800b011446aa.png)

今回は図面ではなくBIMデータとして扱いたいので、GLTF（GLB）で出力しDynamoでRevitモデルに変換することにします。

:::note warn
ややこしいですが、Polycamの「GLTF」を選択するとGLBが出力されます。
:::


### GLB/GLTF とは
__GLTF (The GL Transmission Format)__ は、WebGLやOpenGLをはじめとする、ランタイム向けの３Dフォーマットです。基本的にGLTFは、JSON形式のGLTFファイル本体と、そこから参照されるメッシュデータや画像ファイル群で構成されています。これらを1つにまとめたバイナリファイルは __GLB（GLTF-Binary）__ と呼ばれており、相互に変換することがで


### GLB/GLTF の構成

GLB/GLTFは基本的に以下の構成を取ります。
* .gltfファイル（メタ情報が記述されたJSONファイル）
* .binファイル（頂点データなどが格納されたバイナリファイル）
* テクスチャ画像ファイル
* シェーダーファイル

また、.gltfファイル内の要素は以下のような関係になっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/e20ea197-32fa-d4ea-c28d-aac58d2482eb.png)
`scene` → `node` → `mesh` → `accessor` → `bufferView` → `buffer` → `.binファイル`を辿っていくと、ジオメトリを取り出せることがわかります。詳しくは公式Githubページにあるチートシートを確認してください。

https://github.com/KhronosGroup/glTF/blob/main/specification/2.0/figures/gltfOverview-2.0.0b.png

PolycamのGLBでは、窓やドアなどの各部材が１つの`mesh`で構成されています。また、`mesh`の`name`タグに「object_0」のような部材名称が格納されており、その名称から部材種別を判断できます。

* 窓：`window_<数字>`
* ドア：`door_<数字>`
* 壁：`wall_<数字>`
* 床：`floor_<数字>`
* 家具：`object_<数字>`
```json:Polycam.glb
  "meshes": [
    {
      "name": "window_0",
      "primitives": [
        {
          "attributes": {
            "POSITION": 68
          },
          "indices": 69,
          "material": 34,
          "mode": 4
        }
      ]
    },
    {
      "name": "door_0",
      "primitives": [
        {
          "attributes": {
            "POSITION": 74
          },
          "indices": 75,
          "material": 37,
          "mode": 4
        }
      ]
    }
  ]
```



# Dynamo で GLB → Revit に変換
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/22607a45-9cf9-490d-1c67-6e89e7e8f932.png)
DynamoのPythonスクリプトを使ってGLBからRevitモデルをつくります。本記事ではRevit2022を使用していますが、他のバージョンでも問題なく動作するかと思います。

> __環境__
> Revit 2022
> Dynamo 2.12
> Python 3.8.3


### ライブラリのロード
GLBを読み込むために、__gltflib__ というライブラリを使用します。また数値計算に __numpy__ を使用します。本記事ではAnacondaでDynamo用の環境を用意してライブラリをインストールしています。
```shell:PowerShell
$ pip install pygltflib numpy
```

`sys.path.append`でパスを追加してライブラリをインポートします。
```python:Python スクリプト
# 標準ライブラリ
import sys, clr, struct, dataclasses, math
from System.Collections.Generic import List

# 外部ライブラリ
sys.path.append(r'C:\Users\<ユーザー名>\.conda\envs\Dynamo383\Lib\site-packages')
from pygltflib import *
import numpy as np
```

Dynamo初心者でも分かりやすいように、なるべく __RevitNodes__ を使って実装します。
```python:Python スクリプト
# dynamoノード
clr.AddReference("RevitNodes")
from Revit import Elements as RE
clr.AddReference('ProtoGeometry')
from Autodesk import DesignScript as DS

# revit api
clr.AddReference("RevitAPIUI")
from  Autodesk.Revit.UI import *
from  Autodesk.Revit.DB import *
from  Autodesk.Revit.UI.Selection import *

# WinForms
clr.AddReference('System.Windows.Forms')
clr.AddReference('System.Drawing')
import System.Drawing
import System.Windows.Forms
from System.Drawing import *
from System.Windows.Forms import *
```

### 部材種別クラス

__dataclass__ で部材種別を格納する`InstanceType`クラスをつくります。__dataclass__ はPython3.7以降で使える機能で、デコレータの引数`frozen=True`でイミュータブルになります。
```python:Python スクリプト
@dataclasses.dataclass(frozen=True)
class InstanceType:
    WINDOW: str = "window"
    DOOR: str = "door"
    WALL: str = "wall"
    FLOOR: str = "floor"
    OBJECT: str = "object"
    ROOM: str = "room"
    LEVEL: str = "level"
```

### インスタンス作成に必要なデータを格納するクラス
__dataclass__ でファミリインスタンス作成に必要なデータを格納する`InstanceData`クラスをつくります。
```python:Python スクリプト
@dataclasses.dataclass
class InstanceData:
    name: str = "" # オブジェクト名
    srfs: List[DS.Geometry.Surface] = dataclasses.field(default_factory=list) # surfaceリスト
    mesh: DS.Geometry.Mesh = None # メッシュ
    base_pt: DS.Geometry.Point = None# 基準点
    base_line: DS.Geometry.Curve = None # 基準線
    base_crvs: List[DS.Geometry.Curve] = dataclasses.field(default_factory=list) # 外形線
    type: InstanceType = None # 部材種別
    height: float = 0 # 高さ
```

### ファイル選択ダイアログ
Revit API のファイル選択ダイアログでGLBのパスを取得します。
```python:Python スクリプト
def get_glb_path():
    dialog = FileOpenDialog("GLB (*.glb)|*.glb")
    res = dialog.Show()
    path  = ModelPathUtils.ConvertModelPathToUserVisiblePath(dialog.GetSelectedModelPath())
    if path == None: exit()
    return path
```


### GLBからデータを読み取るクラス
GLBから必要な情報を読み取る`GLBReader`クラスをつくります。
1. __gltflib__ でGLBをロードします。
2. `get_geometry`関数でGLBのMeshからジオメトリを取得します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/f7173af1-0e4d-29eb-96b2-5ce5fb820432.png)
3. `get_isdatas`関数でジオメトリからファミリンスタンス作成に必要な情報を算出し、`InstanceData`クラスに格納します。
```python:Python スクリプト
class GLBReader:
    # gltfからモデル化に必要なデータを読み取るクラス
    
    windows, doors, walls, floors, objects, baselevel = [],[],[],[],[],0


    def __init__(self, glb_path):
        glb = GLTF2().load(glb_path)
        instance_datas = self.get_geometry(glb)
        self.get_isdatas(instance_datas)


    def get_geometry(self, glb):
        instance_datas = []
        
        for mesh in glb.meshes:
            isdata = InstanceData()
            isdata.name = mesh.name
            isdata.type = self.get_type(isdata.name)
            
            # プリミティブは1つだけ取得
            primitive = mesh.primitives[0]

            # バッファから頂点のバイナリデータを取得
            accessor = glb.accessors[primitive.attributes.POSITION]
            bufferView = glb.bufferViews[accessor.bufferView]
            buffer = glb.buffers[bufferView.buffer]
            data = glb.get_data_from_buffer_uri(buffer.uri)

            # バイナリデータから頂点の座標を取得
            vertices = []
            for i in range(accessor.count):
                index = bufferView.byteOffset + accessor.byteOffset + i*12  # 座標情報の場所
                d = data[index:index+12]  # 座標情報
                v = struct.unpack("<fff", d)   # 座標情報をbase64からfloatに変換
                vertices.append(DS.Geometry.Point.ByCoordinates(v[2]*1000,v[0]*1000,v[1]*1000))
                
            # バイナリデータから各トライアングルの頂点番号を取得
            idx_accessor = glb.accessors[primitive.indices]
            idx_bufferView = glb.bufferViews[idx_accessor.bufferView] 
            num = np.frombuffer(
                glb.binary_blob(),
                dtype = "uint8",
                offset = idx_bufferView.byteOffset + idx_accessor.byteOffset,
                count = idx_bufferView.byteLength
            ).reshape((-1, 4))
            indices = []
            for n in num: indices.append(n[0]+n[1]*256+n[2]*256*256+n[3]*256*256*256)
                
            # 頂点グループを作成
            idx_group = []
            for i in range(len(indices[::3])):
                idx = indices[3*i:3*i+3]
                idx_group.append(DS.Geometry.IndexGroup.ByIndices(idx[0], idx[1], idx[2]))
            
            # meshを作成
            isdata.mesh = DS.Geometry.Mesh.ByPointsFaceIndices(vertices, idx_group)
            
            # 家具以外はsurfaceを作成（あとで使う）
            if isdata.type != InstanceType.OBJECT:
                for idx in idx_group:
                    pts = [vertices[idx.A], vertices[idx.B], vertices[idx.C], vertices[idx.A]]
                    crv = DS.Geometry.PolyCurve.ByPoints(pts)
                    srf = DS.Geometry.Surface.ByPatch(crv)
                    isdata.srfs.append(srf)
                
            # 壁要素は上面のみ取得する
            if isdata.type == InstanceType.WALL and not self.is_flat(isdata.srfs): continue
                
            instance_datas.append(isdata)
            
        return instance_datas


    def get_isdatas(self, instance_datas):
        # 部材ごとに基準面を返す
        
        for isdata in instance_datas:
            
            if isdata.type == InstanceType.OBJECT:
                # 家具の場合
                self.objects.append(isdata)
            
            elif isdata.type == InstanceType.WINDOW:
                # 窓の場合
                try: srf = DS.Geometry.Surface.ByUnion(isdata.srfs)
                except: srf = isdata.srfs[0]
                isdata.base_line, isdata.base_pt = self.get_base_line(srf)
                isdata.height = self.get_height(srf)
                self.windows.append(isdata)

            elif isdata.type == InstanceType.DOOR:
                # ドアの場合
                try: srf = DS.Geometry.Surface.ByUnion(isdata.srfs)
                except: srf = isdata.srfs[0]
                isdata.base_line, isdata.base_pt = self.get_base_line(srf)
                isdata.height = self.get_height(srf)
                self.doors.append(isdata)
            
            elif isdata.type == InstanceType.WALL:
                # 壁の場合
                edge_crvs = self.get_top_edge(isdata.srfs)
                center_crv = self.get_center_crv(edge_crvs)
                isdata.base_crvs = center_crv
                self.walls.append(isdata)
            
            elif isdata.type == InstanceType.FLOOR:
                # 床の場合
                edge_crvs = self.get_top_edge(isdata.srfs)
                if edge_crvs is None: continue
                isdata.base_crvs = edge_crvs
                self.floors.append(isdata)
                self.baselevel = edge_crvs[0].StartPoint.Z


    def get_type(self, name):
        #オブジェクト名から部材種別を判定する

        if name[:6] == "object": return InstanceType.OBJECT
        elif name[:6] == "window": return InstanceType.WINDOW
        elif name[:4] == "door": return InstanceType.DOOR
        elif name[:4] == "wall": return InstanceType.WALL
        elif name[:5] == "floor": return InstanceType.FLOOR


    def get_base_line(self, srf):
        # 垂直な面の基点・底辺を取得

        crvs = DS.Geometry.Surface.PerimeterCurves(srf)
        pt = DS.Geometry.Point.ByCoordinates(0,0,10**5)
        for crv in crvs:
            spt = crv.StartPoint
            ept = crv.EndPoint
            if (spt.Z+ept.Z)/2 >= pt.Z: continue
            pt = DS.Geometry.Point.ByCoordinates((spt.X+ept.X)/2, (spt.Y+ept.Y)/2, (spt.Z+ept.Z)/2)       
        return [crv, pt]


    def get_height(self, srf):
        # 面の垂直高さを取得

        box = DS.Geometry.BoundingBox.ByGeometry(srf)
        mx = box.MaxPoint.Z
        mn = box.MinPoint.Z
        height = mx-mn
        return height


    def get_length(self, pt1, pt2):
        # 2点間の距離を算出

        a = np.array([pt1.X, pt1.Y, pt1.Z])
        b = np.array([pt2.X, pt2.Y, pt2.Z])
        length = float(np.linalg.norm(b-a))
        return length


    def is_overlaped(self, crv1, crv2):
        # 2つの線分が重なっているかチェック

        pt1 = crv1.StartPoint
        pt2 = crv1.EndPoint
        pt3 = crv2.StartPoint
        pt4 = crv2.EndPoint
        d13 = self.get_length(pt1, pt3)
        d23 = self.get_length(pt2, pt3)
        d14 = self.get_length(pt1, pt4)
        d24 = self.get_length(pt2, pt4)
        d = self.get_length(pt1, pt2)
        if math.isclose(d13+d23, d) and (math.isclose(d14+d24, d) or math.isclose(d14, d24+d) or math.isclose(d14, d24-d)): return True
        elif math.isclose(d13, d23-d) and (math.isclose(d14+d24, d) or math.isclose(d14, d24+d)): return True
        elif math.isclose(d13, d23+d) and (math.isclose(d14+d24, d) or math.isclose(d14, d24-d)): return True
        return False


    def get_center_crv(self, edge_crvs):
        # 壁の中心線を取得

        crvs = []
        for crv in edge_crvs: crvs.append(crv.Offset(-50))
        for i,crv1 in enumerate(crvs):
            for j,crv2 in enumerate(crvs):
                if i == j: continue
                if not self.is_overlaped(crv1, crv2): continue
                crvs.remove(crv2)
        return crvs


    def is_flat(self, srfs):
        # surfaceのリストが水平かチェック

        for srf in srfs:
            box = DS.Geometry.BoundingBox.ByGeometry(srf)
            mx = box.MaxPoint.Z
            mn = box.MinPoint.Z
            if mx != mn: return False
        return True
    

    def get_top_edge(self, srfs):
        # オブジェクトの上面を取得

        lst = []
        for srf in srfs:
            vtx = DS.Geometry.Surface.NormalAtParameter(srf)
            if vtx.Z == 1: lst.append(srf)
        if len(lst) == 0: return None
        
        top_srfs = []
        try: top_srfs.append(DS.Geometry.Surface.ByUnion(lst))
        except: top_srfs.extend(lst)
        
        edge_crvs = []
        for srf in top_srfs:
            edge_crvs.extend(DS.Geometry.Surface.PerimeterCurves(srf))
            
        return edge_crvs
```

### メインダイアログ
__WinForm__ でダイアログをつくります。Windowsアプリケーション開発では、XAML（マークアップ言語）で視覚的な部分を定義する __WPF__ を採用するのが一般的です。しかし小規模かつシンプルなUIであれば __WinForm__ の方がお手軽に実装できます。

ダイアログには下記の要素を実装します。
* 実行中の処理を表示する`label1`
* 進歩状況を示す`progressBar1`
* コマンドを実行する`button1`
* コマンドをキャンセルする`button2`

`button1`がクリックされたら各処理を順に実行していきます。
1. ファイル選択ダイアログでGLBのファイルパスを取得
2. `GLBReader`でGLBファイルを読み取る
3. ファミリインスタンスを配置

```Python:Python スクリプト
class MainForm(System.Windows.Forms.Form):

    def __init__(self):
        self.InitializeComponent()
        self.path = get_glb_path()


    def InitializeComponent(self):
        self._progressBar1 = System.Windows.Forms.ProgressBar()
        self._label1 = System.Windows.Forms.Label()
        self._button1 = System.Windows.Forms.Button()
        self._button2 = System.Windows.Forms.Button()
        self.SuspendLayout()

        # progressBar1
        self._progressBar1.Location = System.Drawing.Point(15, 50)
        self._progressBar1.Name = "progressBar1"
        self._progressBar1.Size = System.Drawing.Size(370, 20)
        self._progressBar1.Minimum = 0
        self._progressBar1.Maximum = 8

        # label1
        self._label1.Location = System.Drawing.Point(18, 20)
        self._label1.Name = "label1"
        self._label1.Size = System.Drawing.Size(300, 23)
        self._label1.TabIndex = 1
        self._label1.Text = "「実行」をクリックして処理を開始します。"
        
        # button1
        self._button1.Location = System.Drawing.Point(210, 80)
        self._button1.Name = "button1"
        self._button1.Size = System.Drawing.Size(80, 25)
        self._button1.TabIndex = 1
        self._button1.Text = "実行"
        self._button1.UseVisualStyleBackColor = True
        self._button1.Click += self.Button1Click
        
        # button2
        self._button2.Location = System.Drawing.Point(305, 80)
        self._button2.Name = "button2"
        self._button2.Size = System.Drawing.Size(80, 25)
        self._button2.TabIndex = 1
        self._button2.Text = "キャンセル"
        self._button2.UseVisualStyleBackColor = True
        self._button2.Click += self.Button2Click
        
        # Form
        self.ClientSize = System.Drawing.Size(400, 120)
        self.Controls.Add(self._label1)
        self.Controls.Add(self._button1)
        self.Controls.Add(self._button2)
        self.Controls.Add(self._progressBar1)
        self.Name = "MainForm"
        self.Text = "タイトル"
        self.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen
        self.ResumeLayout(False)
        self.PerformLayout()
    

    def update(self, text, progress_value):
        self._label1.Text = text
        self._label1.Update()
        if progress_value <= 8:
            self._progressBar1.Value = progress_value
            self._progressBar1.Update()
        

    def Button1Click(self, sender, e):
        fType = IN[0]
        wType = IN[1]
        wType2 = IN[2]
        dType = IN[3]
        oType = IN[4]
        oMat = IN[5]
        
        self.update("GLBを読み込み中...", 1)
        glb_data = GLBReader(self.path)

        self.update("レベルを作成中...", 2)
        level = RE.Level.ByElevationAndName(glb_data.baselevel, "RoomPlan")
        
        self.update("床を作成中...", 3)
        for isdata in glb_data.floors:
            pts = []
            for crv in isdata.base_crvs:
                pts.append(crv.StartPoint)
            pts.append(pts[0])
            polycrv = DS.Geometry.PolyCurve.ByPoints(pts)
            RE.Floor.ByOutlineTypeAndLevel(polycrv, fType, level)
        
        self.update("壁を作成中...", 4)
        dic = {}
        for isdata in glb_data.walls:
            for crv in isdata.base_crvs:
                z = crv.StartPoint.Z - level.Elevation
                crv.Translate(0,0,-z)
                wall = RE.Wall.ByCurveAndHeight(crv, z, level, wType)
                dic[crv] = wall

        self.update("ドアを作成中...", 5)
        for isdata in glb_data.doors:
            host = None
            d = 10**5
            for crv in dic:
                distance = DS.Geometry.DistanceTo(isdata.base_pt, crv)
                if distance < d:
                    d = distance
                    host = dic[crv]
            RE.FamilyInstance.ByHostAndPoint(dType, host, isdata.base_pt)
        
        self.update("窓を作成中...", 6)
        for isdata in glb_data.windows:
            window =RE.Wall.ByCurveAndHeight(isdata.base_line, isdata.height, level, wType2)
            RE.Element.SetParameterByName(window, "基準レベル オフセット", isdata.base_line.StartPoint.Z-glb_data.baselevel)

        self.update("家具を作成中...", 7)
        for isdata in glb_data.objects:
            RE.DirectShape.ByMesh(isdata.mesh, oType, oMat, isdata.name)
        
        self.update("処理が完了しました。", 8)
        self.Close()
        

    def Button2Click(self, sender, e):
        self.Close()
```

### メインダイアログを実行
最後にメインダイアログを実行します。
```python
System.Windows.Forms.Application.EnableVisualStyles()
form = MainForm()
System.Windows.Forms.Application.Run(form)
```


# 動かしてみる
Dynamoを実行すると、ファイル選択ダイアログとメインダイアログが順に表示され、モデルが作成できています。
![q7smz-cutt5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2059946/87bff62a-734c-a284-029c-0c80c1556da7.gif)


# 参考リンク

https://qiita.com/emadurandal/items/1a034c4addd7ff8b5184

https://www.codegrid.net/series/webgl/

https://pypi.org/project/pygltflib/

https://www.revitapidocs.com/2022/