Blender OBJ/MTL エクスポータ(UE5モード付き)(ソースコードと実行結果)

UE5モード時は,メッシュ三角形化、座標系変換(X前方、Z上方)、スケール変換(1m=100cm)を行う.

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/

必要なライブラリのインストール

コマンドプロンプトを管理者として実行(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する


pip install customtkinter

Blender 4 以降のインストール

コマンドプロンプトを管理者として実行(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する


winget install --scope machine --id BlenderFoundation.Blender -e

Blender OBJ/MTL エクスポータGUI版プログラム

ソースコード


# Blender OBJ/MTL エクスポータ GUI版
# 特徴技術名: Blender Python API (bpy)
# 出典: Blender Foundation (2024). Blender Python API Documentation. https://docs.blender.org/api/current/
# 特徴機能: bpy.ops.wm.obj_export()によるパラメータ制御を伴うOBJ/MTL形式エクスポート。座標系変換、スケール調整、メッシュ三角形化などの制御が可能
# 学習済みモデル: 使用なし
# 方式設計:
#   - 関連利用技術:
#     - customtkinter: UIを提供するtkinterの拡張ライブラリ。ファイル選択ダイアログ、オプション設定、リアルタイムログ表示を実現
#     - subprocess: Blenderをバックグラウンドプロセスとして実行し、標準出力をリアルタイムで取得
#     - threading: GUI応答性維持のための非同期処理実装
#     - pathlib/os/shutil: ファイルシステム操作とテクスチャファイルのコピー処理
#   - 入力と出力: 入力: Blenderファイル(.blend)、出力: OBJ/MTLファイルとテクスチャ
#   - 処理手順:
#     1. GUIでオプション設定とファイル選択
#     2. Blenderをバックグラウンドプロセスで起動
#     3. bpyを使用してBlenderファイルを開く
#     4. 指定オプションに基づきモディファイヤ適用、マテリアル最適化を実行
#     5. bpy.ops.wm.obj_export()でOBJ/MTL形式にエクスポート(UE5モード時は座標系変換とスケール調整)
#     6. テクスチャのコピーとMTLファイルのパス修正
#   - 前処理、後処理:
#     - 前処理: モディファイヤの自動適用(全オブジェクト対象、エクスポート時のみ)、重複マテリアルの検出と統合
#     - 後処理: MTLファイル内のmap_Kdパスをファイル名のみに修正(UE5互換性向上)、texturesフォルダへのテクスチャ自動コピー
#   - 追加処理: UE5モード時のメッシュ三角形化、座標系変換(X前方、Z上方)、スケール変換(1m=100cm)
#   - 調整を必要とする設定値: なし(GUIで全オプションを動的に設定可能)
# 将来方策: なし(全設定はGUIで調整可能)
# その他の重要事項:
#   - Blender 4.5以降が必要
#   - 元のBlenderファイルは変更されない(エクスポート時のみの適用)
#   - GUI起動: python <Python プログラムファイル名>
# 前準備:
#   - Blenderのインストール(4.5以降推奨)
#   - Blenderの実行パスをシステムのPATH環境変数に追加(推奨)
#   - pip install customtkinter

import os
import shutil
import customtkinter as ctk
from tkinter import filedialog, messagebox
import threading
import sys
import tempfile
import subprocess
import platform

# 設定定数
WINDOW_WIDTH = 800  # GUIウィンドウの幅
WINDOW_HEIGHT = 700  # GUIウィンドウの高さ
BLENDER_VERSIONS = ['4.5', '4.2', '4.1', '4.0']  # サポートするBlenderバージョン
TEXTURE_FOLDER_NAME = 'textures'  # テクスチャ保存フォルダ名
UE5_SCALE = 100.0  # UE5向けスケール変換値(1m = 100cm)

# GUI設定定数
LOG_HEIGHT = 300  # ログ表示エリアの高さ(ピクセル)
ENTRY_WIDTH = 400  # 入力フィールドの幅(ピクセル)
PADDING_X = 10  # 水平方向のパディング
PADDING_Y = 5  # 垂直方向のパディング

# CustomTkinterの設定
ctk.set_appearance_mode('system')  # システムの設定に従う(ライト/ダーク)
ctk.set_default_color_theme('blue')  # カラーテーマ

# グローバル変数(GUI用)
root = None
log_text = None
blend_path = None
output_dir = None
ue5_mode = None
apply_mods = None
opt_materials = None
copy_tex = None


def find_blender_executable():
    """Blenderの実行可能ファイルを探す"""
    # まずPATHから探す
    if shutil.which('blender'):
        return 'blender'

    # Windows用の共通パスを定義
    paths = []
    for ver in BLENDER_VERSIONS:
        paths.append(f'C:\\Program Files\\Blender Foundation\\Blender {ver}\\blender.exe')
    paths.extend([
        r'C:\Program Files\Blender Foundation\Blender\blender.exe',
        r'C:\Program Files (x86)\Blender Foundation\Blender\blender.exe',
    ])

    for path in paths:
        if os.path.exists(path):
            return path

    return None


def log(message):
    """ログメッセージを表示"""
    if root and log_text:
        root.after(0, lambda: log_text.insert('end', message) or log_text.see('end'))


def browse_blend_file():
    """Blenderファイルを選択"""
    filename = filedialog.askopenfilename(
        title='Blenderファイルを選択',
        filetypes=[('Blender files', '*.blend'), ('All files', '*.*')]
    )
    if filename:
        blend_path.set(filename)
        # デフォルトの出力ディレクトリを設定
        if not output_dir.get():
            output_dir.set(os.path.dirname(filename))


def browse_output_dir():
    """出力ディレクトリを選択"""
    dirname = filedialog.askdirectory(title='出力ディレクトリを選択')
    if dirname:
        output_dir.set(dirname)


def export_thread():
    """エクスポート処理(別スレッド)"""
    try:
        # Blenderの実行可能ファイルを探す
        blender_path = find_blender_executable()
        if not blender_path:
            log('\nエラー: Blenderが見つかりません\n')
            log('以下のいずれかの方法で解決してください:\n')
            log('1. BlenderをPATH環境変数に追加\n')
            log('2. Blenderを標準的な場所にインストール\n')
            messagebox.showerror('エラー', 'Blenderが見つかりません。\nBlenderがインストールされていることを確認してください。')
            return

        # 出力ディレクトリの処理
        output_directory = output_dir.get() if output_dir.get() else os.path.dirname(blend_path.get())

        # Blender内で実行するスクリプトを作成
        script_content = f'''# -*- coding: utf-8 -*-
import bpy
import os
import shutil

UE5_SCALE = {UE5_SCALE}
TEXTURE_FOLDER_NAME = '{TEXTURE_FOLDER_NAME}'

# 設定値
blend_path = r'{blend_path.get()}'
output_dir = r'{output_directory}'
ue5_mode = {ue5_mode.get()}
apply_mods = {apply_mods.get()}
opt_materials = {opt_materials.get()}
copy_tex = {copy_tex.get()}

try:
    # Blenderファイルを開く
    bpy.ops.wm.open_mainfile(filepath=blend_path)

    # 出力パスの設定
    blend_name = os.path.splitext(os.path.basename(blend_path))[0]

    # 出力パスを絶対パスに変換
    output_dir = os.path.abspath(output_dir)
    output_path = os.path.join(output_dir, blend_name + '.obj')

    # モディファイヤの適用
    if apply_mods:
        try:
            # オブジェクトモードに切り替え
            if bpy.context.mode != 'OBJECT':
                bpy.ops.object.mode_set(mode='OBJECT')

            # 全オブジェクトを走査してモディファイヤを適用
            for obj in bpy.data.objects:
                if obj.type == 'MESH' and obj.modifiers:
                    # オブジェクトを選択してアクティブに設定
                    bpy.ops.object.select_all(action='DESELECT')
                    obj.select_set(True)
                    bpy.context.view_layer.objects.active = obj

                    # モディファイヤをリストにコピー(削除時のインデックス変更対策)
                    modifiers_to_apply = [mod.name for mod in obj.modifiers]

                    for modifier_name in modifiers_to_apply:
                        try:
                            if modifier_name in obj.modifiers:
                                bpy.ops.object.modifier_apply(modifier=modifier_name)
                                print(f'モディファイヤ適用: {{obj.name}}.{{modifier_name}}')
                        except Exception as e:
                            print(f'モディファイヤ {{modifier_name}} の適用に失敗: {{str(e)}}')
        except Exception as e:
            print(f'モディファイヤ適用中にエラーが発生: {{str(e)}}')

    # マテリアルの最適化
    if opt_materials:
        try:
            materials = {{}}
            material_map = {{}}

            for mat in bpy.data.materials:
                if mat.use_nodes:
                    # マテリアルの主要プロパティをキーとして使用
                    key = (
                        round(mat.diffuse_color[0], 3),
                        round(mat.diffuse_color[1], 3),
                        round(mat.diffuse_color[2], 3),
                        round(mat.metallic, 3),
                        round(mat.roughness, 3)
                    )
                    if key in materials:
                        material_map[mat.name] = materials[key]
                        print(f'マテリアル統合: {{mat.name}} -> {{materials[key]}}')
                    else:
                        materials[key] = mat.name
                        material_map[mat.name] = mat.name

            # オブジェクトのマテリアルを統合されたものに置き換え
            for obj in bpy.data.objects:
                if obj.type == 'MESH' and obj.data.materials:
                    for i, mat in enumerate(obj.data.materials):
                        if mat and mat.name in material_map:
                            new_mat_name = material_map[mat.name]
                            if new_mat_name != mat.name:
                                obj.data.materials[i] = bpy.data.materials[new_mat_name]
        except Exception as e:
            print(f'マテリアル最適化中にエラーが発生: {{str(e)}}')

    # 全オブジェクトを選択
    bpy.ops.object.select_all(action='SELECT')

    # OBJエクスポート設定
    print(f'エクスポート開始: {{output_path}}')
    if ue5_mode:
        # UE5向け設定:座標系変換とスケール調整
        bpy.ops.wm.obj_export(
            filepath=output_path,
            export_selected_objects=False,
            apply_modifiers=False,  # モディファイヤは事前に適用済み
            export_uv=True,
            export_normals=True,
            export_colors=True,
            export_materials=True,
            export_triangulated_mesh=True,
            export_object_groups=True,
            export_material_groups=True,
            forward_axis='X',
            up_axis='Z',
            global_scale=UE5_SCALE
        )
        print(f'UE5モード: 座標系変換(X前方,Z上方), スケール={{UE5_SCALE}}, 三角形化')
    else:
        # 標準設定
        bpy.ops.wm.obj_export(
            filepath=output_path,
            export_selected_objects=False,
            apply_modifiers=False,  # モディファイヤは事前に適用済み
            export_uv=True,
            export_normals=True,
            export_colors=True,
            export_materials=True,
            export_triangulated_mesh=False,
            export_object_groups=True,
            export_material_groups=True,
            forward_axis='NEGATIVE_Z',
            up_axis='Y',
            global_scale=1.0
        )
        print(f'標準モード: Blenderデフォルト座標系')

    # テクスチャのコピーとMTLファイルの修正
    if copy_tex:
        mtl_path = output_path.replace('.obj', '.mtl')
        if os.path.exists(mtl_path):
            try:
                # texturesフォルダ作成
                textures_dir = os.path.join(output_dir, TEXTURE_FOLDER_NAME)
                os.makedirs(textures_dir, exist_ok=True)
                print(f'テクスチャフォルダ作成: {{textures_dir}}')

                # MTLファイルを読み込んで修正
                with open(mtl_path, 'r', encoding='utf-8') as f:
                    lines = f.readlines()

                new_lines = []
                texture_count = 0
                for line in lines:
                    if line.strip().startswith('map_'):
                        parts = line.split(None, 1)
                        if len(parts) == 2:
                            texture_path = parts[1].strip()

                            # Windowsパスのバックスラッシュをスラッシュに変換
                            texture_path = texture_path.replace('\\\\', '/')

                            # テクスチャファイルの絶対パス取得
                            if not os.path.isabs(texture_path):
                                # 相対パスの場合、Blenderファイルのディレクトリを基準とする
                                texture_path = os.path.join(os.path.dirname(blend_path), texture_path)

                            texture_path = os.path.normpath(texture_path)
                            tex_name = os.path.basename(texture_path)

                            # テクスチャファイルが存在する場合のみコピー
                            if os.path.exists(texture_path) and os.path.isfile(texture_path):
                                dest_path = os.path.join(textures_dir, tex_name)
                                try:
                                    shutil.copy2(texture_path, dest_path)
                                    print(f'テクスチャコピー成功: {{tex_name}}')
                                    texture_count += 1
                                except Exception as e:
                                    print(f'テクスチャコピー失敗 {{tex_name}}: {{str(e)}}')
                            else:
                                print(f'テクスチャファイルが見つかりません: {{texture_path}}')

                            # MTLファイル内のパスを相対パスに変更(スラッシュを使用)
                            new_line = f'{{parts[0]}} {{TEXTURE_FOLDER_NAME}}/{{tex_name}}\\n'
                            new_lines.append(new_line)
                        else:
                            new_lines.append(line)
                    else:
                        new_lines.append(line)

                # MTLファイルを上書き
                with open(mtl_path, 'w', encoding='utf-8') as f:
                    f.writelines(new_lines)

                print(f'MTLファイル更新完了: {{texture_count}}個のテクスチャを処理')

            except Exception as e:
                print(f'テクスチャ処理中にエラーが発生: {{str(e)}}')

    mode_str = 'UE5モード' if ue5_mode else '標準モード'
    print(f'\\nエクスポート完了({{mode_str}})')
    print(f'OBJファイル: {{output_path}}')
    print(f'MTLファイル: {{output_path.replace(".obj", ".mtl")}}')

    # 出力ファイルサイズを表示(MB単位)
    if os.path.exists(output_path):
        obj_size = os.path.getsize(output_path) / (1000 * 1000)  # MB単位に修正
        print(f'OBJファイルサイズ: {{obj_size:.2f}} MB')

except Exception as e:
    print(f'エクスポート中にエラーが発生しました: {{str(e)}}')
    import traceback
    traceback.print_exc()
'''

        # 一時スクリプトファイル作成
        with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as f:
            f.write(script_content)
            temp_script = f.name

        # Blenderをバックグラウンドで実行
        cmd = [
            blender_path,
            '--background',
            '--python', temp_script
        ]

        process = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            universal_newlines=True,
            encoding='utf-8'
        )

        # 出力をリアルタイムで表示
        for line in process.stdout:
            log(line)

        process.wait()

        # 一時ファイル削除
        try:
            os.unlink(temp_script)
        except OSError as e:
            log(f'一時ファイル削除失敗: {e}\n')

        if process.returncode == 0:
            log('\nエクスポート完了!\n')
            messagebox.showinfo('完了', 'エクスポートが完了しました')
        else:
            log('\nエラーが発生しました\n')
            messagebox.showerror('エラー', 'エクスポート中にエラーが発生しました')

    except Exception as e:
        log(f'\nエラー: {str(e)}\n')
        messagebox.showerror('エラー', str(e))


def export():
    """エクスポート実行"""
    if not blend_path.get():
        messagebox.showerror('エラー', 'Blenderファイルを選択してください')
        return

    # ログクリア
    log_text.delete('1.0', 'end')

    # ガイダンス表示
    log('========================================\n')
    log('Blender OBJ/MTL エクスポータ\n')
    log('========================================\n')
    log('エクスポート設定:\n')
    log(f'  入力ファイル: {os.path.basename(blend_path.get())}\n')
    log(f'  出力先: {output_dir.get() or os.path.dirname(blend_path.get())}\n')
    log(f'  UE5モード: {"有効" if ue5_mode.get() else "無効"}\n')
    log(f'  モディファイヤ適用: {"有効" if apply_mods.get() else "無効"}\n')
    log(f'  マテリアル最適化: {"有効" if opt_materials.get() else "無効"}\n')
    log(f'  テクスチャコピー: {"有効" if copy_tex.get() else "無効"}\n')
    log('========================================\n')
    log('処理を開始します...\n\n')

    # 別スレッドで実行
    thread = threading.Thread(target=export_thread)
    thread.daemon = True
    thread.start()


def create_gui():
    """GUI作成"""
    global root, log_text, blend_path, output_dir, ue5_mode, apply_mods, opt_materials, copy_tex

    print("========================================")
    print("Blender OBJ/MTL エクスポータ GUI版")
    print("========================================")
    print("概要: BlenderファイルをOBJ/MTL形式でエクスポート")
    print("操作方法:")
    print("  1. Blenderファイルを選択")
    print("  2. 必要に応じてオプションを設定")
    print("  3. エクスポート実行ボタンをクリック")
    print("注意事項:")
    print("  - Blender 4.5以降が必要です")
    print("  - 元のBlenderファイルは変更されません")
    print("========================================")

    root = ctk.CTk()
    root.title('Blender OBJ/MTL エクスポータ')
    root.geometry(f'{WINDOW_WIDTH}x{WINDOW_HEIGHT}')

    # 変数初期化
    blend_path = ctk.StringVar()
    output_dir = ctk.StringVar()
    ue5_mode = ctk.BooleanVar(value=True)
    apply_mods = ctk.BooleanVar(value=False)
    opt_materials = ctk.BooleanVar(value=False)
    copy_tex = ctk.BooleanVar(value=False)

    # メインフレーム
    main_frame = ctk.CTkScrollableFrame(root)
    main_frame.pack(fill='both', expand=True, padx=PADDING_X, pady=PADDING_Y)

    # プログラム概要
    o_frame = ctk.CTkFrame(main_frame)
    o_frame.pack(fill='x', padx=PADDING_X, pady=PADDING_Y)

    ctk.CTkLabel(o_frame, text='プログラム概要', font=('', 16, 'bold')).pack(pady=(10, 5))

    overview_text = '''このプログラムは、BlenderファイルをOBJ/MTL形式でエクスポートします。
Unreal Engine 5向けの最適化機能を備えており、座標系変換、スケール調整、
メッシュの三角形化などを自動で行います。'''

    ctk.CTkLabel(o_frame, text=overview_text, justify='left').pack(padx=20, pady=(5, 10))

    # 利用上の注意点
    n_frame = ctk.CTkFrame(main_frame)
    n_frame.pack(fill='x', padx=PADDING_X, pady=PADDING_Y)

    ctk.CTkLabel(n_frame, text='利用上の注意点', font=('', 16, 'bold')).pack(pady=(10, 5))

    notice_text = '''• Blender 4.5以降が必要です
• UE5モードではメッシュが自動的に三角形化されます
• テクスチャコピー機能使用時は、MTLファイルが自動修正されます
• モディファイヤ適用は元に戻せません(元ファイルは変更されません)'''

    ctk.CTkLabel(n_frame, text=notice_text, justify='left').pack(padx=20, pady=(5, 10))

    # ファイル選択
    file_frame = ctk.CTkFrame(main_frame)
    file_frame.pack(fill='x', padx=PADDING_X, pady=PADDING_Y)

    ctk.CTkLabel(file_frame, text='ファイル設定', font=('', 16, 'bold')).pack(pady=(10, 5))

    # 入力ファイル
    input_frame = ctk.CTkFrame(file_frame)
    input_frame.pack(fill='x', padx=20, pady=5)

    ctk.CTkLabel(input_frame, text='Blenderファイル:').pack(side='left', padx=(10, 5))
    ctk.CTkEntry(input_frame, textvariable=blend_path, width=ENTRY_WIDTH).pack(side='left', padx=5)
    ctk.CTkButton(input_frame, text='参照', command=browse_blend_file, width=80).pack(side='left', padx=(5, 10))

    # 出力ディレクトリ
    output_frame = ctk.CTkFrame(file_frame)
    output_frame.pack(fill='x', padx=20, pady=(5, 10))

    ctk.CTkLabel(output_frame, text='出力ディレクトリ:').pack(side='left', padx=(10, 5))
    ctk.CTkEntry(output_frame, textvariable=output_dir, width=ENTRY_WIDTH).pack(side='left', padx=5)
    ctk.CTkButton(output_frame, text='参照', command=browse_output_dir, width=80).pack(side='left', padx=(5, 10))

    # オプション設定
    opt_frame = ctk.CTkFrame(main_frame)
    opt_frame.pack(fill='x', padx=PADDING_X, pady=PADDING_Y)

    ctk.CTkLabel(opt_frame, text='エクスポートオプション', font=('', 16, 'bold')).pack(pady=(10, 5))

    # オプションのコンテナ
    options_container = ctk.CTkFrame(opt_frame)
    options_container.pack(fill='x', padx=20, pady=(5, 10))

    # UE5モード
    ue5_frame = ctk.CTkFrame(options_container)
    ue5_frame.pack(fill='x', pady=5)
    ctk.CTkCheckBox(ue5_frame, text='UE5モード', variable=ue5_mode).pack(side='left', padx=(10, 20))
    ctk.CTkLabel(ue5_frame, text='座標系とスケールをUE5向けに変換、メッシュを三角形化',
                 text_color='gray').pack(side='left')

    # モディファイヤ適用
    mod_frame = ctk.CTkFrame(options_container)
    mod_frame.pack(fill='x', pady=5)
    ctk.CTkCheckBox(mod_frame, text='モディファイヤ適用', variable=apply_mods).pack(side='left', padx=(10, 20))
    ctk.CTkLabel(mod_frame, text='全モディファイヤを適用してエクスポート(元ファイルは変更されません)',
                 text_color='gray').pack(side='left')

    # マテリアル最適化
    mat_frame = ctk.CTkFrame(options_container)
    mat_frame.pack(fill='x', pady=5)
    ctk.CTkCheckBox(mat_frame, text='マテリアル最適化', variable=opt_materials).pack(side='left', padx=(10, 20))
    ctk.CTkLabel(mat_frame, text='同一設定のマテリアルを統合して出力',
                 text_color='gray').pack(side='left')

    # テクスチャコピー
    tex_frame = ctk.CTkFrame(options_container)
    tex_frame.pack(fill='x', pady=5)
    ctk.CTkCheckBox(tex_frame, text='テクスチャコピー', variable=copy_tex).pack(side='left', padx=(10, 20))
    ctk.CTkLabel(tex_frame, text='使用テクスチャをtexturesフォルダにコピーし、パスを相対化',
                 text_color='gray').pack(side='left')

    # 実行ボタン
    ctk.CTkButton(root, text='エクスポート実行', command=export, height=40).pack(pady=PADDING_Y)

    # ログ出力エリア
    log_frame = ctk.CTkFrame(root)
    log_frame.pack(fill='both', expand=True, padx=PADDING_X, pady=PADDING_Y)

    ctk.CTkLabel(log_frame, text='実行ログ', font=('', 14, 'bold')).pack(pady=(5, 0))

    log_text = ctk.CTkTextbox(log_frame, height=LOG_HEIGHT)
    log_text.pack(fill='both', expand=True, padx=5, pady=5)

    root.mainloop()


# メイン処理
if __name__ == '__main__':
    create_gui()