LoginSignup
6
7

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-01

はじめに

DANSO(建築情報学会) Advent Calendar 2022 の2日目の記事です。
この記事ではRoomPlanについて簡単に紹介し、DynamoをつかってRevitモデルに変換する方法を解説します。

RoomPlan とは

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

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

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

スキャンしてみる

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

  1. Polycamアプリを起動して、画面右下の「ROOM」を選択し、スキャンを開始します。
    IMG_0048.PNG

  2. カメラで部屋を映していくと白いガイドラインが表示され、床、壁、テーブルなどのオブジェクトが生成されていきます。
    IMG_00501.png

  3. 完成したモデルはアプリで閲覧することができます。
    IMG_0051.PNG

スキャンデータの出力

スキャン結果はOBJやGLTF、FBXのようなメッシュモデルとして出力できます。
IMG_0053.PNG

また、2D CADデータ(DXF)として出力してRevitなどのアプリで読み込むこともできます。
image.png

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

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

GLB/GLTF とは

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

GLB/GLTF の構成

GLB/GLTFは基本的に以下の構成を取ります。

  • .gltfファイル(メタ情報が記述されたJSONファイル)
  • .binファイル(頂点データなどが格納されたバイナリファイル)
  • テクスチャ画像ファイル
  • シェーダーファイル

また、.gltfファイル内の要素は以下のような関係になっています。
image.png
scenenodemeshaccessorbufferViewbuffer.binファイルを辿っていくと、ジオメトリを取り出せることがわかります。詳しくは公式Githubページにあるチートシートを確認してください。

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

  • 窓:window_<数字>
  • ドア:door_<数字>
  • 壁:wall_<数字>
  • 床:floor_<数字>
  • 家具:object_<数字>
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
DynamoのPythonスクリプトを使ってGLBからRevitモデルをつくります。本記事ではRevit2022を使用していますが、他のバージョンでも問題なく動作するかと思います。

環境
Revit 2022
Dynamo 2.12
Python 3.8.3

ライブラリのロード

GLBを読み込むために、gltflib というライブラリを使用します。また数値計算に numpy を使用します。本記事ではAnacondaでDynamo用の環境を用意してライブラリをインストールしています。

PowerShell
$ pip install pygltflib numpy

sys.path.appendでパスを追加してライブラリをインポートします。

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 スクリプト
# 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 スクリプト
@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 スクリプト
@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 スクリプト
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
  3. get_isdatas関数でジオメトリからファミリンスタンス作成に必要な情報を算出し、InstanceDataクラスに格納します。
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 スクリプト
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()

メインダイアログを実行

最後にメインダイアログを実行します。

System.Windows.Forms.Application.EnableVisualStyles()
form = MainForm()
System.Windows.Forms.Application.Run(form)

動かしてみる

Dynamoを実行すると、ファイル選択ダイアログとメインダイアログが順に表示され、モデルが作成できています。
q7smz-cutt5.gif

参考リンク

6
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
7