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

【概要】Blenderに3Dアセット(OBJ/MTLファイル)を自動配置するPythonプログラムについて解説する。ランダム配置では衝突回避アルゴリズムを用いて複数オブジェクトを配置し、Blenderをバックグラウンドで実行して結果を保存する。

プログラム利用ガイド

1. このプログラムの利用シーン

Blenderのシーンに複数の3Dオブジェクトを配置するためのツールである。手作業で一つずつ配置する代わりに、ランダム配置またはJSON指定による一括配置を行うことができる。森林、都市景観、群衆など、同一オブジェクトを多数配置するシーン制作に適している。

2. 主な機能

3. 基本的な使い方

  1. 起動とBlender選択:

    プログラムを実行すると、検出されたBlenderのインストール一覧が表示される。使用するBlenderの番号を入力する。

  2. Blenderファイル選択:

    オブジェクトを配置する既存のBlenderファイルを選択する。

  3. オブジェクト選択:

    1(OBJ/MTLファイル)または2(プリミティブ形状)を選択する。OBJ/MTLファイルの場合はファイルダイアログで選択する。

  4. 配置方法選択:

    1(ランダム配置)または2(カスタム配置)を選択する。ランダム配置では範囲、最小距離、配置数を設定できる。

  5. プレビュー確認:

    配置プレビューウィンドウが表示される。任意のキーを押して続行するか、nを入力して再生成する。

  6. 保存:

    保存先のBlenderファイル名を指定する。元のファイルとは別名で保存することを推奨する。

4. 便利な機能

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 --accept-source-agreements --accept-package-agreements
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 --id Codeium.Windsurf -e --silent --accept-source-agreements --accept-package-agreements

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

その他の前準備

Blenderをインストールしておく.

Blender への3Dアセット(obj,mtl)の配置プログラム

概要

このプログラムは、Blenderに3Dアセット(OBJ/MTLファイル)を自動配置する。ランダム配置ではサンプリングベースの衝突回避アルゴリズムにより、オブジェクト間の最小距離を保証しながら配置位置を決定する。Blenderをバックグラウンドで実行し、配置結果をBlenderファイルとして保存する。

主要技術

サンプリングベースの配置探索

LaValleが提唱したサンプリングベースの動作計画手法[1]に基づき、ランダムな候補位置を生成し、既存オブジェクトとの距離判定により有効な配置を探索する。各オブジェクトについて最大100回の試行を行い、設定された最小距離以上を保証する位置を決定する。

Blender Python API

Blender公式のPython API(bpy)[2]を使用し、OBJファイルのインポート、コレクション管理、インスタンス化、ファイル保存を実行する。バックグラウンドモードでBlenderを起動し、GUIなしで処理を完結する。

技術的特徴

実装の特色

参考文献

[1] LaValle, S. M. (2006). Planning Algorithms. Cambridge University Press. Chapter 5: Sampling-Based Motion Planning, pp. 185-280. https://lavalle.pl/planning/

[2] Blender Foundation. Blender Python API Documentation. https://docs.blender.org/api/current/

ソースコード


# 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()

実験・研究スキルの基礎:Windows で学ぶ3Dオブジェクト配置実験

1. 実験・研究のスキル構成要素

実験や研究を行うには、以下の5つの構成要素を理解する必要がある。

1.1 実験用データ

このプログラムでは3Dアセットファイル(OBJ/MTLファイル)または プリミティブ形状が実験用データである。配置先となる既存のBlenderファイルも実験環境の一部となる。

1.2 実験計画

何を明らかにするために実験を行うのかを定める。

計画例:

1.3 プログラム

実験を実施するためのツールである。このプログラムはサンプリングベースの衝突回避アルゴリズムとBlender Python API(bpy)を使用している。

1.4 プログラムの機能

このプログラムは3つの主要パラメータで配置を制御する。

入力パラメータ:

出力情報:

配置モード:

1.5 検証(結果の確認と考察)

プログラムの実行結果を観察し、パラメータの影響を考察する。

基本認識:

観察のポイント:

2. 間違いの原因と対処方法

2.1 プログラムのミス(人為的エラー)

Blenderが見つからない

OBJファイルのインポートに失敗する

プレビューウィンドウが表示されない

2.2 期待と異なる結果が出る場合

指定した数のオブジェクトが配置されない

オブジェクトが重なって配置される

配置が偏っている

Blenderファイルが保存されない

3. 実験レポートのサンプル

配置密度と衝突回避成功率の関係

実験目的:

限られた配置範囲内で、衝突回避を維持しながら配置できるオブジェクト数の上限を見つける。

実験計画:

配置範囲を-10~10(20×20の領域)、最小距離を2.0に固定し、配置数を変化させて配置成功率を調べる。

実験方法:

プログラムを実行し、以下の基準で評価する:

実験結果:

指定配置数 配置成功数 配置成功率 配置の均一性 備考
xxxx x x% x x
xxxx x x% x x
xxxx x x% x x
xxxx x x% x x

考察:

結論:

(例文)配置範囲20×20、最小距離2.0の条件では、配置数xxxxまでは安定して配置が可能であった。これを超える配置が必要な場合は、配置範囲の拡大または最小距離の縮小が必要である。また、均一な配置を求める場合はカスタム配置の使用が適切である。