はじめに
DANSO(建築情報学会) Advent Calendar 2022 の2日目の記事です。
この記事ではRoomPlanについて簡単に紹介し、DynamoをつかってRevitモデルに変換する方法を解説します。
※2024/06/11追記:C#で実装したアドインの動画もあります
RoomPlan とは
iOS16/iPadOS16の新機能である RoomPlan は、iPhoneやiPadのLiDARスキャナを使って屋内空間をスキャンするためのAPIです。家具の種類や部屋のサイズなどの要素を含む3Dモデルを生成することができます。
このRoomPlanに対応した機能が3Dスキャナーアプリ 「Polycam」 に追加され、今年9月から一般ユーザも使えるようになりました。
- Polycamは無料でインストールできますが、RoomPlanの機能やGLTF以外のデータ出力は有料版が必要です。
- LiDARスキャナ搭載のiPhone ProもしくはiPad Proが必要です。
スキャンしてみる
大学の研究室で実際にスキャンを行ってみます。
スキャンデータの出力
スキャン結果はOBJやGLTF、FBXのようなメッシュモデルとして出力できます。
また、2D CADデータ(DXF)として出力してRevitなどのアプリで読み込むこともできます。
今回は図面ではなく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ファイル内の要素は以下のような関係になっています。
scene
→ node
→ mesh
→ accessor
→ bufferView
→ buffer
→ .binファイル
を辿っていくと、ジオメトリを取り出せることがわかります。詳しくは公式Githubページにあるチートシートを確認してください。
PolycamのGLBでは、窓やドアなどの各部材が1つのmesh
で構成されています。また、mesh
のname
タグに「object_0」のような部材名称が格納されており、その名称から部材種別を判断できます。
- 窓:
window_<数字>
- ドア:
door_<数字>
- 壁:
wall_<数字>
- 床:
floor_<数字>
- 家具:
object_<数字>
"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 に変換
DynamoのPythonスクリプトを使ってGLBからRevitモデルをつくります。本記事ではRevit2022を使用していますが、他のバージョンでも問題なく動作するかと思います。
環境
Revit 2022
Dynamo 2.12
Python 3.8.3
ライブラリのロード
GLBを読み込むために、gltflib というライブラリを使用します。また数値計算に numpy を使用します。本記事ではAnacondaでDynamo用の環境を用意してライブラリをインストールしています。
$ pip install pygltflib numpy
sys.path.append
でパスを追加してライブラリをインポートします。
# 標準ライブラリ
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 を使って実装します。
# 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
でイミュータブルになります。
@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
クラスをつくります。
@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のパスを取得します。
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
クラスをつくります。
- gltflib でGLBをロードします。
-
get_geometry
関数でGLBのMeshからジオメトリを取得します。
-
get_isdatas
関数でジオメトリからファミリンスタンス作成に必要な情報を算出し、InstanceData
クラスに格納します。
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
がクリックされたら各処理を順に実行していきます。
- ファイル選択ダイアログでGLBのファイルパスを取得
-
GLBReader
でGLBファイルを読み取る - ファミリインスタンスを配置
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を実行すると、ファイル選択ダイアログとメインダイアログが順に表示され、モデルが作成できています。
参考リンク