Blender に3Dアセット(obj,mtl)の配置(ソースコードと実行結果)


Python開発環境,ライブラリ類
ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.htmlで詳しく解説しているので、必要に応じて参照してください。
Python 3.12 のインストール
インストール済みの場合は実行不要。
管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。
REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul
【関連する外部ページ】
Python の公式ページ: https://www.python.org/
AI エディタ Windsurf のインストール
Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。
管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。
winget install --scope machine Codeium.Windsurf -e --silent
【関連する外部ページ】
Windsurf の公式ページ: https://windsurf.com/
その他の前準備
Blenderをインストールしておく.
Blender への3Dアセット(obj,mtl)の配置プログラム
概要
このプログラムは、ユーザーの要求に基づいて複数の3Dオブジェクトを配置する。ランダム配置では、衝突回避を考慮しながら配置を計画する。Blenderをバックグラウンド実行して、配置を行い Blender ファイルを保存する。
主要技術
- 衝突回避アルゴリズム: 各オブジェクト間の距離を計算し、設定された最小距離以上を保証する位置を探索する[1]。
参考文献
[1] LaValle, S. M. (2006). Planning Algorithms. Cambridge University Press. Chapter 5: Sampling-Based Motion Planning, pp. 185-280. Available at: https://lavalle.pl/planning/
ソースコード
# Blender への3Dアセット(obj,mtl)の配置プログラム
# Blender Python API (bpy) の基本構造
# コンテキストとデータ構造
#
# bpy.context: 現在のシーン、選択オブジェクト等のコンテキスト情報
# bpy.data: メッシュ、マテリアル、テクスチャ等の全データブロック
# bpy.ops: オペレーター(インポート、変換等の操作)
# bpy.types: クラス定義(オブジェクト、メッシュ等)
# 座標系
#
# Blenderは右手系Z-up座標系を使用
# OBJファイル(通常Y-up)からの変換が必要な場合あり
# OBJ/MTLインポート
#
# 軸変換の重要性
#
# TripoSR等のAI生成モデルは座標系が異なる場合が多い
# axis_forwardとaxis_upパラメータで適切に設定
# 一般的な設定パターン:
# Y-up to Z-up: axis_forward='-Z', axis_up='Y'
# Z-up維持: axis_forward='Y', axis_up='Z'
# 大量アセット管理のベストプラクティス
# コレクション(Collection)による階層管理
#
# コレクション作成
# collection = bpy.data.collections.new("3D_Assets")
# bpy.context.scene.collection.children.link(collection)
#
# オブジェクトをコレクションに追加
# obj = bpy.context.selected_objects[0]
# collection.objects.link(obj)
# bpy.context.scene.collection.objects.unlink(obj)
# 命名規則とメタデータ
#
# 一貫した命名規則(例:Asset_Category_Number_Variant)
# カスタムプロパティによるメタデータ付与
# obj["asset_type"] = "prop"
# obj["source"] = "tripoSR"
# obj["import_date"] = "2024-01-01"
# パフォーマンス最適化
# インスタンス化(Instancing)
#
# リンク複製によるメモリ効率化
# original = bpy.data.objects['original_object']
# for i in range(100):
# instance = original.copy()
# instance.data = original.data # メッシュデータを共有
# instance.location = (i * 2, 0, 0)
# collection.objects.link(instance)
# マテリアルとテクスチャの最適化
# マテリアルの統合
#
# def consolidate_materials(obj):
# 重複マテリアルの削除
# materials = {}
# for slot in obj.material_slots:
# if slot.material:
# mat_name = slot.material.name.split('.')[0]
# if mat_name not in materials:
# materials[mat_name] = slot.material
# else:
# slot.material = materials[mat_name]
# テクスチャパスの管理
#
# import os
#
# def fix_texture_paths(base_path):
# for image in bpy.data.images:
# if image.source == 'FILE':
# filename = os.path.basename(image.filepath)
# new_path = os.path.join(base_path, filename)
# if os.path.exists(new_path):
# image.filepath = new_path
# ランダム配置with衝突回避
#
# import random
# from mathutils import Vector
#
# def random_placement_with_collision(objects, bounds=(-10, 10), min_distance=2.0):
# placed_positions = []
#
# for obj in objects:
# valid_position = False
# attempts = 0
#
# while not valid_position and attempts < 100:
# pos = Vector((
# random.uniform(bounds[0], bounds[1]),
# random.uniform(bounds[0], bounds[1]),
# 0
# ))
#
# valid_position = True
# for placed_pos in placed_positions:
# if (pos - placed_pos).length < min_distance:
# valid_position = False
# break
#
# attempts += 1
#
# if valid_position:
# obj.location = pos
# placed_positions.append(pos)
import os
import sys
import json
import random
import math
import numpy as np
import cv2
import tempfile
import subprocess
from datetime import datetime
import winreg
from PIL import Image, ImageDraw, ImageFont
try:
import tkinter as tk
from tkinter import filedialog, messagebox
except ImportError:
print("tkinterが利用できません。ファイルパスを手動入力してください。")
tk = None
class BlenderObjectPlacer:
def __init__(self):
self.bounds = (-10, 10)
self.min_distance = 2.0
self.num_objects = 10
self.obj_path = None
self.mtl_path = None
self.use_primitive = False
self.primitive_type = 'cube'
self.placements = []
self.blender_path = None
self.placement_mode = 'random' # 'random' or 'custom'
self.input_blend_path = None # 既存のBlenderファイル
def find_blender_windows(self):
"""Windows環境でBlenderのパスを検索"""
possible_paths = []
# レジストリから検索
try:
reg_paths = [
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Blender Foundation"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Blender Foundation"),
]
for hkey, subkey in reg_paths:
try:
with winreg.OpenKey(hkey, subkey) as key:
for i in range(winreg.QueryInfoKey(key)[0]):
version = winreg.EnumKey(key, i)
with winreg.OpenKey(key, version) as version_key:
install_path = winreg.QueryValue(version_key, "")
blender_exe = os.path.join(install_path, "blender.exe")
if os.path.exists(blender_exe):
possible_paths.append(blender_exe)
except:
pass
except:
pass
# 一般的なインストール場所を確認
common_paths = [
r"C:\Program Files\Blender Foundation",
r"C:\Program Files (x86)\Blender Foundation",
os.path.expanduser(r"~\AppData\Local\Blender Foundation"),
]
for base_path in common_paths:
if os.path.exists(base_path):
for item in os.listdir(base_path):
blender_exe = os.path.join(base_path, item, "blender.exe")
if os.path.exists(blender_exe):
possible_paths.append(blender_exe)
# PATH環境変数を確認
for path in os.environ.get("PATH", "").split(os.pathsep):
blender_exe = os.path.join(path, "blender.exe")
if os.path.exists(blender_exe):
possible_paths.append(blender_exe)
# 重複を削除
return list(set(possible_paths))
def select_blender_path(self):
"""Blenderの実行パスを選択"""
found_paths = self.find_blender_windows()
if found_paths:
print("\n検出されたBlenderのインストール:")
for i, path in enumerate(found_paths):
print(f"{i+1}: {path}")
choice = input(f"\n使用するBlenderを選択 (1-{len(found_paths)}) または手動入力は0: ").strip()
try:
idx = int(choice)
if 1 <= idx <= len(found_paths):
self.blender_path = found_paths[idx-1]
return True
except:
pass
# 手動入力
if tk:
root = tk.Tk()
root.withdraw()
self.blender_path = filedialog.askopenfilename(
title="blender.exeを選択",
filetypes=[("Executable", "*.exe"), ("All files", "*.*")]
)
root.destroy()
else:
self.blender_path = input("\nblender.exeのフルパスを入力: ").strip()
if self.blender_path and os.path.exists(self.blender_path):
return True
else:
print("エラー: Blenderが見つかりません")
return False
def load_custom_placement(self):
"""カスタム配置JSONファイルを読み込み"""
# サンプルJSON表示
print("\nJSONファイルのサンプル:")
sample = '''[
{"x": 0, "y": 0, "z": 0, "rotation": 0},
{"x": 5, "y": 5, "z": 0, "rotation": 1.57},
{"x": -5, "y": 3, "z": 0, "rotation": 3.14}
]'''
print(sample)
print("\nJSONファイルの指定")
# ファイル選択
if tk:
root = tk.Tk()
root.withdraw()
json_path = filedialog.askopenfilename(
title="配置JSONファイルを選択",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")]
)
root.destroy()
else:
json_path = input("JSONファイルのパスを入力: ").strip().replace('"', '')
if not json_path or not os.path.exists(json_path):
print("ファイルが見つかりません")
return False
# JSON読み込み
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, list):
print("エラー: JSONはリスト形式である必要があります")
return False
# 各エントリを検証
valid_placements = []
for i, entry in enumerate(data):
try:
# 必須フィールドチェック
if not all(key in entry for key in ['x', 'y', 'z', 'rotation']):
print(f"行 {i+1}: {entry}")
print("スキップ")
continue
# 数値チェック
x = float(entry['x'])
y = float(entry['y'])
z = float(entry['z'])
rotation = float(entry['rotation'])
valid_placements.append({
'position': [x, y, z],
'rotation': rotation
})
except (ValueError, TypeError) as e:
print(f"行 {i+1}: {entry}")
print("スキップ")
continue
if not valid_placements:
print("有効な配置データがありません")
return False
self.placements = valid_placements
print(f"\n{len(self.placements)}個の配置データを読み込みました")
return True
except json.JSONDecodeError as e:
print(f"JSONパースエラー: {e}")
return False
except Exception as e:
print(f"ファイル読み込みエラー: {e}")
return False
def get_user_inputs(self):
"""ユーザーからの入力を取得"""
print("\n=== 3Dオブジェクト配置ツール (Windows版) ===")
print("\n[概要説明]")
print("Blenderファイルに3Dオブジェクトを自動配置するツールです")
print("OBJ/MTLファイルまたはプリミティブ形状を複数配置できます")
print("\n[操作方法]")
print("1. Blenderの実行ファイルを選択")
print("2. 既存のBlenderファイルを選択")
print("3. 配置するオブジェクトを選択")
print("4. 配置方法を選択(ランダムまたはJSON指定)")
print("\n[注意事項]")
print("- 元のBlenderファイルは変更されません")
print("- 新しいファイル名で保存されます")
print("- Blender 4.5対応")
# Blenderパス設定
if not self.select_blender_path():
return False
# 既存Blenderファイル選択
print("\n既存のBlenderファイルを選択")
if tk:
root = tk.Tk()
root.withdraw()
messagebox.showinfo("ファイル選択", "オブジェクトを配置する既存のBlenderファイルを選択してください")
self.input_blend_path = filedialog.askopenfilename(
title="既存のBlenderファイルを選択(配置を追加するファイル)",
filetypes=[("Blend files", "*.blend"), ("All files", "*.*")]
)
root.destroy()
else:
self.input_blend_path = input("\n既存のBlenderファイルのパスを入力: ").strip().replace('"', '')
if not self.input_blend_path or not os.path.exists(self.input_blend_path):
print("エラー: Blenderファイルが見つかりません")
return False
# オブジェクトタイプ選択
choice = input("\n1: OBJ/MTLファイルを使用\n2: プリミティブ形状を使用\n選択してください (1 or 2) [1]: ").strip()
if choice == '2':
self.use_primitive = True
print("\nプリミティブ形状:")
print("1: 立方体 (Cube)")
print("2: 球 (Sphere)")
print("3: 円柱 (Cylinder)")
print("4: 六角柱 (Hexagonal Prism)")
prim_choice = input("選択してください [1]: ").strip()
prim_map = {'1': 'cube', '2': 'sphere', '3': 'cylinder', '4': 'hexagon'}
self.primitive_type = prim_map.get(prim_choice, 'cube')
else:
# ファイル選択
if tk:
root = tk.Tk()
root.withdraw()
print("\nOBJファイルを選択してください...")
self.obj_path = filedialog.askopenfilename(
title="OBJファイルを選択",
filetypes=[("OBJ files", "*.obj"), ("All files", "*.*")]
)
if self.obj_path:
# MTLファイルを自動検索
base_name = os.path.splitext(self.obj_path)[0]
auto_mtl = base_name + ".mtl"
if os.path.exists(auto_mtl):
use_auto = input(f"\nMTLファイルを検出: {os.path.basename(auto_mtl)}\nこれを使用しますか? (Y/n): ").strip().lower()
if use_auto != 'n':
self.mtl_path = auto_mtl
if not self.mtl_path:
print("\nMTLファイルを選択してください...")
self.mtl_path = filedialog.askopenfilename(
title="MTLファイルを選択",
initialdir=os.path.dirname(self.obj_path),
filetypes=[("MTL files", "*.mtl"), ("All files", "*.*")]
)
root.destroy()
else:
self.obj_path = input("\nOBJファイルのパスを入力: ").strip().replace('"', '')
self.mtl_path = input("MTLファイルのパスを入力: ").strip().replace('"', '')
# 配置方法選択
print("\n配置方法を選択:")
print("1: ランダム配置")
print("2: カスタム配置(JSONファイル)")
placement_choice = input("選択してください (1 or 2) [1]: ").strip()
if placement_choice == '2':
self.placement_mode = 'custom'
if not self.load_custom_placement():
return False
else:
self.placement_mode = 'random'
# ランダム配置のパラメータ
print(f"\n現在の設定:")
print(f"配置範囲: {self.bounds[0]} ~ {self.bounds[1]}")
print(f"最小距離: {self.min_distance}")
print(f"配置数: {self.num_objects}")
if input("\n設定を変更しますか? (y/N): ").strip().lower() == 'y':
try:
range_input = input(f"配置範囲 (min max) [{self.bounds[0]} {self.bounds[1]}]: ").strip()
if range_input:
min_val, max_val = map(float, range_input.split())
self.bounds = (min_val, max_val)
dist_input = input(f"最小距離 [{self.min_distance}]: ").strip()
if dist_input:
self.min_distance = float(dist_input)
num_input = input(f"配置数 [{self.num_objects}]: ").strip()
if num_input:
self.num_objects = int(num_input)
except ValueError:
print("無効な入力です。デフォルト値を使用します。")
return True
def generate_placements(self):
"""ランダム配置を生成"""
self.placements = []
placed_positions = []
for i in range(self.num_objects):
valid_position = False
attempts = 0
while not valid_position and attempts < 100:
x = random.uniform(self.bounds[0], self.bounds[1])
y = random.uniform(self.bounds[0], self.bounds[1])
z = 0
pos = np.array([x, y, z])
valid_position = True
for placed_pos in placed_positions:
# 2D平面での衝突判定(Z座標は0で固定のため)
if np.linalg.norm(pos[:2] - placed_pos[:2]) < self.min_distance:
valid_position = False
break
attempts += 1
if valid_position:
rotation = random.uniform(0, 2 * math.pi)
self.placements.append({
'position': pos.tolist(),
'rotation': rotation
})
placed_positions.append(pos)
def preview_placement(self):
"""OpenCVで配置をプレビュー(Blenderと同じ座標系)"""
if not self.placements:
print("配置データがありません")
return False
# 配置範囲を計算
positions = [p['position'] for p in self.placements]
x_coords = [p[0] for p in positions]
y_coords = [p[1] for p in positions]
if self.placement_mode == 'custom':
# カスタム配置の場合は範囲を自動計算
margin = 5
min_x, max_x = min(x_coords) - margin, max(x_coords) + margin
min_y, max_y = min(y_coords) - margin, max(y_coords) + margin
# 正方形にするため、大きい方の範囲を使用
x_range = max_x - min_x
y_range = max_y - min_y
max_range = max(x_range, y_range)
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
bounds_min = min(center_x - max_range/2, center_y - max_range/2)
bounds_max = max(center_x + max_range/2, center_y + max_range/2)
bounds = (bounds_min, bounds_max)
else:
bounds = self.bounds
img_size = 800
scale = img_size / (bounds[1] - bounds[0])
img = np.ones((img_size, img_size, 3), dtype=np.uint8) * 255
# グリッド描画
grid_step = max(int(scale), 1) # ゼロ除算防止
for i in range(0, img_size, grid_step):
cv2.line(img, (i, 0), (i, img_size), (200, 200, 200), 1)
cv2.line(img, (0, i), (img_size, i), (200, 200, 200), 1)
# 原点線(Blenderと同じ座標系: X軸右、Y軸上)
origin_x = int((0 - bounds[0]) * scale)
origin_y = int(img_size - (0 - bounds[0]) * scale)
cv2.line(img, (origin_x, 0), (origin_x, img_size), (100, 100, 100), 2)
cv2.line(img, (0, origin_y), (img_size, origin_y), (100, 100, 100), 2)
# オブジェクト描画
for i, placement in enumerate(self.placements):
pos = placement['position']
rotation = placement['rotation']
# Blenderと同じ座標系での表示(Y軸上向きが正)
px = int((pos[0] - bounds[0]) * scale)
py = int(img_size - (pos[1] - bounds[0]) * scale)
# Z座標に応じて色を変える
if pos[2] > 0:
color = (255, 0, 0) # 青(浮いている)
else:
color = (0, 0, 255) # 赤(地面)
radius = int(self.min_distance * scale / 2) if self.placement_mode == 'random' else 20
radius = max(radius, 5) # 最小半径を保証
cv2.circle(img, (px, py), radius, color, -1)
cv2.circle(img, (px, py), radius, (0, 0, 0), 2)
# 方向を矢印で表現(Blenderと同じ向き)
arrow_length = radius
arrow_end_x = px + int(arrow_length * math.cos(rotation))
arrow_end_y = py - int(arrow_length * math.sin(rotation))
cv2.arrowedLine(img, (px, py), (arrow_end_x, arrow_end_y), (0, 255, 0), 2)
# インデックス表示(日本語フォント対応)
FONT_PATH = 'C:/Windows/Fonts/meiryo.ttc'
FONT_SIZE = 12
try:
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
img_pil = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(img_pil)
draw.text((px-10, py-5), str(i), font=font, fill=(255, 255, 255))
img = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
except:
# フォールバック: OpenCVのデフォルトフォント
cv2.putText(img, str(i), (px-10, py+5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
# 情報テキスト表示(日本語対応)
info_text = [
f"配置モード: {self.placement_mode.upper()}",
f"オブジェクト数: {len(self.placements)}",
f"範囲: {bounds[0]:.1f} ~ {bounds[1]:.1f}",
"任意のキーで続行..."
]
FONT_PATH = 'C:/Windows/Fonts/meiryo.ttc'
FONT_SIZE = 16
try:
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
img_pil = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(img_pil)
y_offset = 20
for text in info_text:
draw.text((10, y_offset), text, font=font, fill=(0, 0, 0))
y_offset += 25
img = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
except:
# フォールバック
y_offset = 20
for text in info_text:
cv2.putText(img, text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)
y_offset += 25
cv2.imshow("Placement Preview", img)
cv2.waitKey(0)
cv2.destroyAllWindows()
return input("\nこの配置で続行しますか? (Y/n): ").strip().lower() != 'n'
def create_blender_script(self, json_path, input_blend_path, output_path):
"""Blender内で実行するスクリプトを作成(Blender 4.5対応)"""
# Windowsパスの処理
output_path = os.path.normpath(output_path)
input_blend_path = os.path.normpath(input_blend_path)
json_path = os.path.normpath(json_path)
script = f'''
import bpy
import json
import os
from mathutils import Vector, Euler
from datetime import datetime
# JSONファイルから設定を読み込み
with open(r"{json_path}", "r", encoding="utf-8") as f:
config = json.load(f)
placements = config["placements"]
use_primitive = config["use_primitive"]
primitive_type = config["primitive_type"]
obj_path = config.get("obj_path", "")
mtl_path = config.get("mtl_path", "")
# 既存のBlenderファイルを開く
bpy.ops.wm.open_mainfile(filepath=r"{input_blend_path}")
def consolidate_materials(obj):
"""重複マテリアルの削除"""
materials = {{}}
for slot in obj.material_slots:
if slot.material:
mat_name = slot.material.name.split('.')[0]
if mat_name not in materials:
materials[mat_name] = slot.material
else:
slot.material = materials[mat_name]
def fix_texture_paths(base_path):
"""テクスチャパスの管理"""
for image in bpy.data.images:
if image.source == 'FILE':
filename = os.path.basename(image.filepath)
new_path = os.path.join(base_path, filename)
if os.path.exists(new_path):
image.filepath = new_path
image.reload()
print(f"テクスチャパス修正: {{filename}}")
def create_primitive(name, prim_type='cube'):
"""プリミティブ形状を作成"""
if prim_type == 'cube':
bpy.ops.mesh.primitive_cube_add(size=2)
elif prim_type == 'sphere':
bpy.ops.mesh.primitive_uv_sphere_add(radius=1)
elif prim_type == 'cylinder':
bpy.ops.mesh.primitive_cylinder_add(radius=1, depth=2)
elif prim_type == 'hexagon':
bpy.ops.mesh.primitive_cylinder_add(vertices=6, radius=1, depth=2)
obj = bpy.context.active_object
obj.name = name
return obj
def import_obj_file():
"""OBJファイルをインポート(Blender 4.5対応)"""
before_import = set(bpy.data.objects)
# Blender 4.0以降の新しいAPI
bpy.ops.wm.obj_import(
filepath=obj_path,
forward_axis='NEGATIVE_Z',
up_axis='Y'
)
imported_objects = list(set(bpy.data.objects) - before_import)
# テクスチャパスの修正
if obj_path:
texture_base_path = os.path.dirname(obj_path)
fix_texture_paths(texture_base_path)
# マテリアルの統合
for obj in imported_objects:
if obj.type == 'MESH':
consolidate_materials(obj)
return imported_objects[0] if imported_objects else None
# コレクション作成または取得
collection_name = "Random_Placed_Objects"
if collection_name in bpy.data.collections:
collection = bpy.data.collections[collection_name]
else:
collection = bpy.data.collections.new(collection_name)
bpy.context.scene.collection.children.link(collection)
# レイヤーコレクションをアクティブに
layer_collection = bpy.context.view_layer.layer_collection.children[collection_name]
bpy.context.view_layer.active_layer_collection = layer_collection
# オリジナルオブジェクトを取得/作成
if use_primitive:
original = create_primitive("Original_" + primitive_type, primitive_type)
else:
original = import_obj_file()
if not original:
raise Exception("Failed to import object")
# オリジナルをコレクションに追加(デフォルトコレクションから移動)
if original.name not in collection.objects:
if original.name in bpy.context.scene.collection.objects:
bpy.context.scene.collection.objects.unlink(original)
collection.objects.link(original)
# オリジナルを非表示
original.hide_set(True)
original.hide_render = True
# 配置実行
for i, placement in enumerate(placements):
pos = placement['position']
rotation = placement['rotation']
# インスタンス作成
instance = original.copy()
instance.data = original.data # メッシュデータを共有
instance.name = f"Instance_{{i:03d}}"
# 位置と回転設定
instance.location = Vector((pos[0], pos[1], pos[2]))
instance.rotation_euler = Euler((0, 0, rotation), 'XYZ')
# コレクションに追加
collection.objects.link(instance)
# メタデータ追加
instance["placement_index"] = i
instance["source"] = "tripoSR" if not use_primitive else "primitive"
instance["import_date"] = datetime.now().strftime("%Y-%m-%d")
# マテリアル統計情報
material_count = len([m for m in bpy.data.materials if m.users > 0])
texture_count = len([i for i in bpy.data.images if i.users > 0])
print(f"マテリアル数: {{material_count}}")
print(f"テクスチャ数: {{texture_count}}")
print(f"配置オブジェクト数: {{len(placements)}}")
# ファイル保存
bpy.ops.wm.save_as_mainfile(filepath=r"{output_path}")
'''
return script
def execute_blender(self):
"""Blenderをバックグラウンドで実行"""
# 一時ファイル作成
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f:
# パスをWindowsフォーマットに変換
config = {
"placements": self.placements,
"use_primitive": self.use_primitive,
"primitive_type": self.primitive_type,
"obj_path": os.path.normpath(self.obj_path) if self.obj_path else "",
"mtl_path": os.path.normpath(self.mtl_path) if self.mtl_path else ""
}
json.dump(config, f, indent=2, ensure_ascii=False)
json_path = f.name
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as f:
# 出力ファイルパス
if tk:
root = tk.Tk()
root.withdraw()
messagebox.showinfo("保存先選択",
"配置後のBlenderファイルの保存先を指定してください\n" +
"(元のファイルとは別名で保存することを推奨します)")
default_name = "配置後_" + os.path.basename(self.input_blend_path)
output_path = filedialog.asksaveasfilename(
title="保存するBlenderファイル名(配置後のファイル)",
defaultextension=".blend",
initialfile=default_name,
filetypes=[("Blend files", "*.blend"), ("All files", "*.*")]
)
root.destroy()
# .lnkファイルの場合の処理
if output_path and output_path.endswith('.lnk'):
output_path = output_path[:-4] # .lnkを削除
else:
print("\n保存するBlenderファイルのパス")
print(f"推奨: 配置後_{os.path.basename(self.input_blend_path)}")
output_path = input("パスを入力 (.blend): ").strip().replace('"', '')
if not output_path:
print("キャンセルされました")
return
if not output_path.endswith('.blend'):
output_path += '.blend'
# デバッグ情報
print(f"\n保存先パス: {output_path}")
print(f"保存先ディレクトリ: {os.path.dirname(output_path)}")
# スクリプト作成
script_content = self.create_blender_script(json_path, self.input_blend_path, output_path)
f.write(script_content)
script_path = f.name
try:
# Blender実行
cmd = [
self.blender_path,
"--background",
"--python", script_path
]
print(f"\nBlenderを実行中...")
print(f"コマンド: {' '.join(cmd)}")
# エンコーディングを指定して実行
result = subprocess.run(cmd, capture_output=True, text=True, errors='replace')
# 結果確認
if result.returncode == 0:
# ファイルの存在確認
if os.path.exists(output_path):
print(f"\n成功!ファイルを保存しました: {output_path}")
print(f"ファイルサイズ: {os.path.getsize(output_path)} bytes")
# 出力から統計情報を表示
for line in result.stdout.split('\n'):
if 'マテリアル数:' in line or 'テクスチャ数:' in line or '配置オブジェクト数:' in line:
print(line)
else:
print(f"\nエラー: ファイルが作成されませんでした: {output_path}")
else:
print(f"\nエラーが発生しました (return code: {result.returncode})")
print("エラー詳細:")
print(result.stderr)
finally:
# 一時ファイル削除
try:
os.unlink(json_path)
os.unlink(script_path)
except:
pass
def run(self):
"""メイン実行"""
if not self.get_user_inputs():
return
# 配置生成とプレビュー
if self.placement_mode == 'random':
while True:
self.generate_placements()
if self.preview_placement():
break
print("\n配置を再生成します...")
else:
# カスタム配置の場合はプレビューのみ
if not self.preview_placement():
return
# Blender実行
self.execute_blender()
# 配置情報をCSV形式で表示
print("\n=== 配置情報 (CSV形式) ===")
print("index,x,y,z,rotation_z")
for i, placement in enumerate(self.placements):
pos = placement['position']
rotation = placement['rotation']
print(f"{i},{pos[0]:.6f},{pos[1]:.6f},{pos[2]:.6f},{rotation:.6f}")
if __name__ == "__main__":
placer = BlenderObjectPlacer()
placer.run()