Blenderでbpy(Blender Python API)を用いてテキストベースで3Dモデリングおよび、マテリアル適用、UV展開等を行う時最大の課題が、要素のインデックスとその対応位置が実行毎にランダムに決まる点です。
文章で説明しても何を言っているか分かりにくいと思うので、下のGIF画像を見てください。
▼実行毎にindexと対応位置が変わる様子
【期待動作】
- オブジェクトの天面が常に選択される
- インデックス「6, 5, 12」の面を選択
- Blenderではbpyの実行毎に選択される面の位置が変わる
天面だけの選択であれば、z軸方向に座標が最も大きい面を3つ選択するなどと言った特別な対応をすることも可能ですが、できれば汎用的に使える方法を取りたいのでこの記事では、「要素のインデックスとその対応位置を固定する方法」についてある程度使える程度に解決したので紹介記事になります。
かなり力技な上に面倒な方法なので、もっと簡単にもっと複雑なモデルにも対応できる方法をご存じの方は是非教えてください。
動作確認環境 : Blender 4.2
version : 2.0 (2024/10/19)
※ 現状の最適化版です。今後更新の可能性が高いです。
※ すべてのオブジェクトに対して動作が保証されるわけではありません。
※ 運用中問題が見つかり次第随時更新します。
ランダムなインデックス位置の解決方法
早速ですが詳細は置いておいて解決方法から書きます。version1.0
→ 各要素の絶対位置が実行毎に微小なずれがあることが分かった
→ 要素の数が多くなるとインデックスの位置にずれが発生
version2.0
座標の絶対位置とオブジェクト内座標からの相対位置に基づく3Dカーソル座標からの距離に応じた再インデックス割り当て
Python:
import bpy
import mathutils
# 3Dカーソル位置更新
def set_3d_cursor_position(position_list=[0, 0, 0]):
bpy.context.scene.cursor.location = (position_list[0], position_list[1], position_list[2])
bpy.context.view_layer.update() # View更新
# スケール変更
def scale_object_to_target_size(obj, target_size=1.0):
# ワールド空間のバウンディングボックスのコーナー取得
bbox_corners = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
# 最小・最大座標取得
min_corner = mathutils.Vector((
min(v.x for v in bbox_corners),
min(v.y for v in bbox_corners),
min(v.z for v in bbox_corners),
))
max_corner = mathutils.Vector((
max(v.x for v in bbox_corners),
max(v.y for v in bbox_corners),
max(v.z for v in bbox_corners),
))
# バウンディングボックスの対角線の長さ計算
bbox_size = (max_corner - min_corner).length
# スケール係数を計算
if bbox_size == 0:
print("Bounding box size is zero. Cannot scale the object.")
return 0
scale_factor = target_size / bbox_size
obj.scale *= scale_factor # スケールを掛ける
return scale_factor
# スケールを元に戻す
def reset_object_scale(obj, scale_factor):
obj.scale *= 1/scale_factor
# アクティブオブジェクトのインデックスを取得してリストへ格納
def get_element_all_index(element_type="FACE"):
obj = bpy.context.object
if not obj or obj.type != 'MESH':
print("No mesh object selected or active object is not a mesh.")
return []
mesh = obj.data
indices = []
if element_type == "VERT":
indices = [v.index for v in mesh.vertices]
elif element_type == "EDGE":
indices = [e.index for e in mesh.edges]
elif element_type == "FACE":
indices = [f.index for f in mesh.polygons]
return indices
# 要素インデックスリスト(index_list)と
# 対応する並びの要素座標リスト(point_list)を取得
def get_element_point(
element_type, # "FACE"/"VERT"/"EDGE"
object_element_index_list # 全要素のインデックス
):
obj = bpy.context.object
if not obj or obj.type != 'MESH':
print("No mesh object selected or active object is not a mesh.")
return []
mesh = obj.data
point_list = [] # 座標リスト(x, y, z)
index_list = [] # indexリスト
matrix_world = obj.matrix_world # オブジェクトの変換行列
if element_type == "VERT":
for index in object_element_index_list:
vertex = mesh.vertices[index]
world_coord = matrix_world @ vertex.co # ワールド座標変換
point_list.append(list(world_coord))
index_list.append(index)
elif element_type == "EDGE":
for index in object_element_index_list:
edge = mesh.edges[index]
vert1 = mesh.vertices[edge.vertices[0]].co
vert2 = mesh.vertices[edge.vertices[1]].co
mid_point = matrix_world @ ((vert1 + vert2) / 2) # ワールド座標変換
point_list.append(list(mid_point))
index_list.append(index)
elif element_type == "FACE":
for index in object_element_index_list:
face = mesh.polygons[index]
world_center = matrix_world @ face.center # ワールド座標変換
point_list.append(list(world_center))
index_list.append(index)
return index_list, point_list
# (x, y, z)座標情報 point_listのデータをx/y/zいずれかの値で昇順ソート(*_sorted_point_list)
# *_sorted_point_listの並びに対応するようにindex_listをソート(*_sorted_index_list)
def sort_point_list_data_target_axis(
point_list, # 座標情報リスト (例: [[x1, y1, z1], [x2, y2, z2], ...])
index_list, # 座標情報に対応するインデックスリスト
target_axis # ソート対象の軸 (0: x, 1: y, 2: z)
):
# 座標リスト/インデックスリストを指定した軸(target_axis)でソート
combined_list = sorted(zip(index_list, point_list), key=lambda x: x[1][target_axis], reverse=False)
# ソートされたインデックスリストと座標リストに分離
sorted_index_list = [x[0] for x in combined_list]
sorted_point_list = [x[1] for x in combined_list]
return sorted_index_list, sorted_point_list
# target_axisの座標値を更新しながら閾値より離れた座標が見つかるまで探索してグループ化
# グループ化した要素のindexと座標をリストへ格納
def get_axis_group_index_list(
sorted_index_list, # ソート済みインデックスリスト
sorted_point_list, # ソート済み座標リスト
threshold, # グループ化閾値
target_axis # 処理対象の軸 (0: x, 1: y, 2: z)
):
min_group_index_list = []
min_group_point_list = []
# 最小座標を基準として初期化
reference_point = sorted_point_list[0]
min_group_index_list.append(sorted_index_list[0])
min_group_point_list.append(reference_point)
# 残りの座標をループでチェック、比較座標を更新しながら閾値でグループ化
for i in range(1, len(sorted_point_list)):
current_point = sorted_point_list[i]
distance = abs(current_point[target_axis] - reference_point[target_axis])
if distance <= threshold:
# 閾値以内ならグループに追加し、比較ポイントを更新
min_group_index_list.append(sorted_index_list[i])
min_group_point_list.append(current_point)
reference_point = current_point # 新しい比較ポイントを更新
else:
break
return min_group_index_list, min_group_point_list
# 閾値でグループ化しながらx->y->zの順に小さいものからインデックスを確定
def index_sort_by_group(
index_list_sorted # index_list : 確定していないindexリスト(昇順ソート)
, x_sorted_index_list # index_list : 処理対象のindexリスト
, x_sorted_point_list # point_list : 処理対象の座標リスト
, threshold # グループ化閾値
, element_type # "FACE"/"VERT"/"EDGE"
):
# インデックスのリストが空になるまでループ
while(len(x_sorted_index_list) > 0):
# グループ化
x_min_index_glist, x_min_point_glist = get_axis_group_index_list(
x_sorted_index_list # x値で昇順ソートされた座標リストに対応するindexリスト
, x_sorted_point_list # x値で昇順ソートされた座標リスト[[x, y, z]]
, threshold # 閾値
, 0 # 処理対象 0=x, 1=y, 2=z
)
# グループ内の要素数が単一の場合 -> index確定
if (len(x_min_index_glist) == 1):
# 確定した座標位置に3Dカーソル移動
set_3d_cursor_position(x_min_point_glist[0])
# 最小位置インデックスと最小インデックス選択
min_value = index_list_sorted[0]
element_select(
element_list=[x_min_index_glist[0], min_value]
, select_mode=element_type
)
# 3Dカーソルからの距離に基づいてインデックスソート
bpy.ops.mesh.sort_elements(type='CURSOR_DISTANCE', elements={element_type})
index_list_sorted.pop(0)
min_index = x_sorted_index_list.index(x_min_index_glist[0])
x_sorted_index_list.pop(min_index)
x_sorted_point_list.pop(min_index)
if min_value in x_sorted_index_list:
min_index = x_sorted_index_list.index(min_value)
x_sorted_index_list[min_index] = x_min_index_glist[0]
# x座標の値から確定しなかった場合
else:
# ソート
y_sorted_index_list, y_sorted_point_list = sort_point_list_data_target_axis(
x_min_point_glist # 3次元座標情報(x, y, z)
, x_min_index_glist # インデックスリスト
, 1 # 対象選択 0=x, 1=y, 2=z
)
# インデックスのリストが空になるまでループ
while (len(y_sorted_index_list) > 0):
# グループ化
y_min_index_glist, y_min_point_glist = get_axis_group_index_list(
y_sorted_index_list # yの値で昇順にソートされた座標のリストに対応するindexのリスト
, y_sorted_point_list # yの値で昇順にソートされた座標のリスト[[x, y, z]]
, threshold # 閾値
, 1 # 処理対象 0=x, 1=y, 2=z
)
# グループ内の要素数が単一の場合 -> index確定
if (len(y_min_index_glist) == 1):
# 確定した座標位置に3Dカーソル移動
set_3d_cursor_position(y_min_point_glist[0])
# 最小位置インデックスと最小インデックス選択
min_value = index_list_sorted[0]
element_select(
element_list=[y_min_index_glist[0], min_value]
, select_mode=element_type
)
# 3Dカーソルからの距離に基づいてインデックスソート
bpy.ops.mesh.sort_elements(type='CURSOR_DISTANCE', elements={element_type})
index_list_sorted.pop(0)
min_index = x_sorted_index_list.index(y_min_index_glist[0])
x_sorted_index_list.pop(min_index)
x_sorted_point_list.pop(min_index)
min_index = y_sorted_index_list.index(y_min_index_glist[0])
y_sorted_index_list.pop(min_index)
y_sorted_point_list.pop(min_index)
if min_value in x_sorted_index_list:
min_index = x_sorted_index_list.index(min_value)
x_sorted_index_list[min_index] = y_min_index_glist[0]
if min_value in y_sorted_index_list:
min_index = y_sorted_index_list.index(min_value)
y_sorted_index_list[min_index] = y_min_index_glist[0]
# y座標の値から確定しなかった場合
else:
# ソート
z_sorted_index_list, z_sorted_point_list = sort_point_list_data_target_axis(
y_min_point_glist # 3次元座標情報(x, y, z)
, y_min_index_glist # インデックスリスト
, 2 # 対象選択 0=x, 1=y, 2=z
)
# インデックスのリストが空になるまでループ
while (len(z_sorted_index_list) > 0):
# グループ化
z_min_index_glist, z_min_point_glist = get_axis_group_index_list(
z_sorted_index_list # zの値で昇順にソートされた座標のリストに対応するindexのリスト
, z_sorted_point_list # zの値で昇順にソートされた座標のリスト[[x, y, z]]
, threshold # 閾値
, 2 # 処理対象 0=x, 1=y, 2=z
)
# グループ内の要素数が単一の場合 -> index確定
if (len(z_min_index_glist) == 1):
# 確定した座標位置に3Dカーソル移動
set_3d_cursor_position(z_min_point_glist[0])
# 最小位置インデックスと最小インデックス選択
min_value = index_list_sorted[0]
element_select(
element_list=[z_min_index_glist[0], min_value]
, select_mode=element_type
)
# 3Dカーソルからの距離に基づいてインデックスをソート
bpy.ops.mesh.sort_elements(type='CURSOR_DISTANCE', elements={element_type})
index_list_sorted.pop(0)
min_index = x_sorted_index_list.index(z_min_index_glist[0])
x_sorted_index_list.pop(min_index)
x_sorted_point_list.pop(min_index)
min_index = y_sorted_index_list.index(z_min_index_glist[0])
y_sorted_index_list.pop(min_index)
y_sorted_point_list.pop(min_index)
min_index = z_sorted_index_list.index(z_min_index_glist[0])
z_sorted_index_list.pop(min_index)
z_sorted_point_list.pop(min_index)
if min_value in x_sorted_index_list:
min_index = x_sorted_index_list.index(min_value)
x_sorted_index_list[min_index] = z_min_index_glist[0]
if min_value in y_sorted_index_list:
min_index = y_sorted_index_list.index(min_value)
y_sorted_index_list[min_index] = z_min_index_glist[0]
if min_value in z_sorted_index_list:
min_index = z_sorted_index_list.index(min_value)
z_sorted_index_list[min_index] = z_min_index_glist[0]
# リスト内の値が閾値以下未満で同等の座標の場合(無限ループ対策)
elif (threshold < 1e-16):
print("[My Warning] Possibility of random index")
# 3Dカーソル移動
set_3d_cursor_position(z_min_point_glist[0])
for i in range(len(z_min_index_glist)):
# 最小位置インデックスと最小インデックスを選択
min_value = index_list_sorted[0]
element_select(
element_list=[z_min_index_glist[i], min_value]
, select_mode=element_type
)
# 3Dカーソルからの距離に基づいてインデックスをソート
bpy.ops.mesh.sort_elements(type='CURSOR_DISTANCE', elements={element_type})
index_list_sorted.pop(0)
min_index = x_sorted_index_list.index(z_min_index_glist[i])
x_sorted_index_list.pop(min_index)
x_sorted_point_list.pop(min_index)
min_index = y_sorted_index_list.index(z_min_index_glist[i])
y_sorted_index_list.pop(min_index)
y_sorted_point_list.pop(min_index)
min_index = z_sorted_index_list.index(z_min_index_glist[i])
z_sorted_index_list.pop(min_index)
z_sorted_point_list.pop(min_index)
if min_value in x_sorted_index_list:
min_index = x_sorted_index_list.index(min_value)
x_sorted_index_list[min_index] = z_min_index_glist[i]
if min_value in y_sorted_index_list:
min_index = y_sorted_index_list.index(min_value)
y_sorted_index_list[min_index] = z_min_index_glist[i]
if min_value in z_sorted_index_list:
min_index = z_sorted_index_list.index(min_value)
z_sorted_index_list[min_index] = z_min_index_glist[i]
# x/y/z座標で確定できない場合
else:
# ソート
x_z_sorted_index_list, x_z_sorted_point_list = sort_point_list_data_target_axis(
z_min_point_glist # 3次元座標情報(x, y, z)
, z_min_index_glist # インデックスリスト
, 0 # 対象選択 0=x, 1=y, 2=z
)
# 閾値更新
threshold_update = threshold / 2
# 再帰呼び出し
index_sort_by_group(index_list_sorted # 参照渡し
, x_z_sorted_index_list # 要素数0->上位呼び出し
, x_z_sorted_point_list
, threshold_update
, element_type
)
# リスト整理
for i in range(len(z_min_index_glist)):
min_index = x_sorted_index_list.index(z_min_index_glist[i])
x_sorted_index_list.pop(min_index)
x_sorted_point_list.pop(min_index)
min_index = y_sorted_index_list.index(z_min_index_glist[i])
y_sorted_index_list.pop(min_index)
y_sorted_point_list.pop(min_index)
min_index = z_sorted_index_list.index(z_min_index_glist[i])
z_sorted_index_list.pop(min_index)
z_sorted_point_list.pop(min_index)
# 要素インデックスソート
def sort_element_index(
element_type="FACE" # "FACE"/"VERT"/"EDGE"
, threshold=1e-3 # グループ化閾値
):
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.mode_set(mode='EDIT')
set_3d_cursor_position() # 3Dカーソル位置初期化
# 全インデックス取得
object_element_index_list = get_element_all_index(
element_type # "FACE"/"VERT"/"EDGE"
)
# インデックスリスト(index_list)に対応する要素座標リスト(point_list)取得
index_list, point_list = get_element_point(
element_type # "FACE"/"VERT"/"EDGE"
, object_element_index_list # 全要素インデックス
)
# ソート
x_sorted_index_list, x_sorted_point_list = sort_point_list_data_target_axis(
point_list # 3次元座標情報(x, y, z)
, index_list # インデックスリスト
, 0 # 対象選択 0=x, 1=y, 2=z
)
all_index_list = x_sorted_index_list.copy() # 全インデックスリスト
all_index_list.sort(reverse=False) # 昇順ソート
# indexソート
index_sort_by_group(
all_index_list # 全インデックスリスト(未確定インデックス)(ソート済)
, x_sorted_index_list # x座標値でソートされた座標リストに対応するインデックスリスト(indexソート対象)
, x_sorted_point_list # x座標値でソートされた座標リスト(indexソート対象)
, threshold # グループ化閾値
, element_type # "FACE"/"VERT"/"EDGE"
)
if (len(all_index_list) != 0): print("[My Warning] random index") # 全インデックスのソート確認
set_3d_cursor_position() # 3Dカーソル位置初期化
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.mode_set(mode='EDIT')
# オブジェクトに以下のクリーンアップを実行
# ・大きさ0を融解:面積が0の面を削除し、1つの頂点にまとめる
# ・孤立を削除:どの面にもつながっていない辺や頂点を削除する
# ・重複頂点を削除:重複している頂点を1つの頂点にまとめる
def cleanup_mesh_object():
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.mode_set(mode='EDIT', toggle=False)
# 頂点を全選択した状態とする
bpy.ops.mesh.select_all(action='SELECT')
# 大きさ0を融解(結合距離 0.0001)
bpy.ops.mesh.dissolve_degenerate(threshold=0.0001)
# 変更を反映するため再び頂点を全選択
bpy.ops.mesh.select_all(action='SELECT')
# 孤立を削除(頂点、辺のみ)
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
# 孤立を削除で全選択が解除されるので再び頂点を全選択
bpy.ops.mesh.select_all()
# 重複頂点を削除(結合距離 0.0001、非選択部の結合無効)
bpy.ops.mesh.remove_doubles(threshold=0.0001, use_unselected=False)
bpy.ops.object.mode_set(mode='OBJECT', toggle=False)
return
# 面、辺、頂点のインデックスソート
def sort_element_index_wrap(element_type="FACE", threshold=1e-5, scale_ratio=2.0):
current_mode = bpy.context.object.mode # 現在のモード保存
cleanup_mesh_object() # 重複要素マージ / 削除
bpy.ops.object.mode_set(mode='OBJECT')
initialize_transform_apply(object_name_list=[bpy.context.object.name])# 全適用
original_location = bpy.context.object.location.copy() # アクティブオブジェクトの現在地保存
bpy.context.object.location = (0.0, 0.0, 0.0) # オブジェクトを原点に移動
bpy.context.view_layer.update() # 更新して位置確定
initialize_transform_apply(object_name_list=[bpy.context.object.name]) # 全適用
obj = bpy.context.active_object
# オブジェクトのスケールを変更,元のスケールを保存
scale_factor = scale_object_to_target_size(obj, target_size=scale_ratio)
initialize_transform_apply(object_name_list=[bpy.context.object.name]) # 全適用
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type='FACE')
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
for space in area.spaces:
if space.type == 'VIEW_3D':
with bpy.context.temp_override(area=area):
sort_element_index(element_type=element_type, threshold=threshold)
bpy.context.view_layer.update() # ソート後データ更新
bpy.ops.object.mode_set(mode='OBJECT')
reset_object_scale(obj, scale_factor) # 処理後,元のスケールに戻す
initialize_transform_apply(object_name_list=[bpy.context.object.name]) # 全適用
bpy.context.object.location = original_location # オブジェクトを元の位置に戻す
bpy.context.view_layer.update() # 位置を確定
initialize_transform_apply(object_name_list=[bpy.context.object.name]) # 全適用
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type=element_type)
bpy.ops.mesh.select_all(action='DESELECT') # 要素選択解除
bpy.ops.object.mode_set(mode=current_mode) # 元のモードに戻す
return
Python:
# 実行方法
sort_element_index_wrap(element_type="FACE")
sort_element_index_wrap(element_type="EDGE")
sort_element_index_wrap(element_type="VERT")
計算量が大きいのでコード内での多用は推奨しません。使用は必要最小限に留めましょう。
コード内で使用されている関数
▼element_selectオブジェクトの要素をインデックスで指定するための関数
▼initialize_transform_apply
全適用+@を行うための関数
▼cleanup_mesh_object
こちらを参考にさせていただいています。
インデックス位置の固定方法詳細
アルゴリズムの名前に詳しくないですが、グループ化とか、クラスタリングに近いアルゴリズムで処理を構成しています。加えてインデックスのソートAPIである「bpy.ops.mesh.sort_elements」を利用して、インデックスの位置固定を実現しようとしています。
▼ 公式ページ
Mesh Operators - Blender Python API
閾値でX軸→Y軸→Z軸の順で小さいものをグループ化しながら、3Dカーソルからの位置に基づいてインデックスをソートするという挙動のつもりで実装しています。
オブジェクトが複雑になればなるほど処理が重くなりますが、状態管理用のリストを増やして使用メモリとのトレードオフを考慮したり、無駄な処理を削れば多少処理が軽くなるはずです。
PythonやBlenderの小数点の丸め込み誤差の仕様や、座標位置の実行毎の誤差があるため、それらを考慮したうえで、オブジェクトのスケーリングや閾値のパラメータを変える必要があるかもしれません。
インデックスの対応位置固定処理の使用例
上のソートを用いた、インデックスの固定処理の必要性の分かるちょっとしたDemoを作ってみました。Python:
def Counter_create():
bpy.ops.object.mode_set(mode='OBJECT')
# 立方体追加
bpy.ops.mesh.primitive_cube_add(
size=2
, location=(0, 0, 0)
, scale=(1,1,1)
)
bpy.context.object.name = "Counter_Base"
# オブジェクト選択
my_active_element = active_element_select(object_name_list=['Counter_Base'])
# Mode切り替え
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type='EDGE')
# ループカット
bpy.ops.mesh.loopcut_slide(
MESH_OT_loopcut={
"number_cuts":1
, "smoothness":0
, "falloff":'INVERSE_SQUARE'
, "object_index":0
, "edge_index":11
}
, TRANSFORM_OT_edge_slide={
"value":-0.6
, "single_side":False
, "use_even":False
}
)
# Mode 切り替え
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type='FACE')
# 要素選択
element_select(
element_list=[9]
, select_mode="FACE"
, object_name_list=["Counter_Base"]
)
# 法線方向への面押し出し、引き込み
bpy.ops.mesh.extrude_region_shrink_fatten(
MESH_OT_extrude_region={}
, TRANSFORM_OT_shrink_fatten={
"value":2.7
}
)
# インデックスソート
sort_element_index_wrap(element_type="FACE")
# 天面を選択
element_select(
element_list=[4, 6, 11]
, select_mode="FACE"
, object_name_list=["Counter_Base"]
)
Counter_create()
55行目
「sort_element_index_wrap(element_type="FACE")」がある場合と、コメントアウトして、59行目のインデックスを変更して、天面を選択したものを比較すると、要素のインデックスとその対応位置を固定する処理の必要性が分かるかと思います。
以上